From 529a72a2bf4901d40e7551c4acaf8219609dcfb9 Mon Sep 17 00:00:00 2001 From: Ben Picolo Date: Mon, 30 Jul 2018 14:23:18 -0400 Subject: [PATCH 001/141] Fix base64 padding for kube config --- config/kube_config.py | 6 ++++-- config/kube_config_test.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index ddd3d02b..3691a18b 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -257,13 +257,15 @@ def _load_oid_token(self, provider): if len(parts) != 3: # Not a valid JWT return None + padding = (4 - len(parts[1]) % 4) * '=' + if PY3: jwt_attributes = json.loads( - base64.b64decode(parts[1]).decode('utf-8') + base64.b64decode(parts[1] + padding).decode('utf-8') ) else: jwt_attributes = json.loads( - base64.b64decode(parts[1] + "==") + base64.b64decode(parts[1] + padding) ) expire = jwt_attributes.get('exp') diff --git a/config/kube_config_test.py b/config/kube_config_test.py index a79efb9a..12d6916d 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -43,6 +43,10 @@ def _base64(string): return base64.encodestring(string.encode()).decode() +def _unpadded_base64(string): + return base64.b64encode(string.encode()).decode().rstrip('=') + + def _format_expiry_datetime(dt): return dt.strftime(EXPIRY_DATETIME_FORMAT) @@ -87,11 +91,13 @@ def _raise_exception(st): TEST_OIDC_TOKEN = "test-oidc-token" TEST_OIDC_INFO = "{\"name\": \"test\"}" -TEST_OIDC_BASE = _base64(TEST_OIDC_TOKEN) + "." + _base64(TEST_OIDC_INFO) +TEST_OIDC_BASE = _unpadded_base64( + TEST_OIDC_TOKEN) + "." + _unpadded_base64(TEST_OIDC_INFO) TEST_OIDC_LOGIN = TEST_OIDC_BASE + "." + TEST_CLIENT_CERT_BASE64 TEST_OIDC_TOKEN = "Bearer %s" % TEST_OIDC_LOGIN TEST_OIDC_EXP = "{\"name\": \"test\",\"exp\": 536457600}" -TEST_OIDC_EXP_BASE = _base64(TEST_OIDC_TOKEN) + "." + _base64(TEST_OIDC_EXP) +TEST_OIDC_EXP_BASE = _unpadded_base64( + TEST_OIDC_TOKEN) + "." + _unpadded_base64(TEST_OIDC_EXP) TEST_OIDC_EXPIRED_LOGIN = TEST_OIDC_EXP_BASE + "." + TEST_CLIENT_CERT_BASE64 TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH) From 2f3247b83715503daa9be353b8b45a8431e168ae Mon Sep 17 00:00:00 2001 From: micw523 Date: Fri, 9 Nov 2018 20:22:11 -0600 Subject: [PATCH 002/141] Travis CI for Python 3.7 --- .travis.yml | 4 ++++ tox.ini | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index c3fefd02..18dcd2fd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,6 +25,10 @@ matrix: env: TOXENV=py36 - python: 3.6 env: TOXENV=py36-functional + - python: 3.7 + env: TOXENV=py37 + - python: 3.7 + env: TOXENV=py37-functional install: - pip install tox diff --git a/tox.ini b/tox.ini index f36f3478..f935a6cd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] skipsdist = True -envlist = py27, py34, py35, py36 +envlist = py27, py34, py35, py36, py37 [testenv] passenv = TOXENV CI TRAVIS TRAVIS_* From 15474efbaf906bf557c4a38392a0b06c95ce7841 Mon Sep 17 00:00:00 2001 From: saberuster Date: Mon, 26 Nov 2018 20:53:25 +0800 Subject: [PATCH 003/141] fix #88 --- stream/ws_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 1cc56cdd..3d087810 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -176,7 +176,7 @@ def update(self, timeout=0): elif op_code == ABNF.OPCODE_BINARY or op_code == ABNF.OPCODE_TEXT: data = frame.data if six.PY3: - data = data.decode("utf-8") + data = data.decode("utf-8", "replace") if len(data) > 1: channel = ord(data[0]) data = data[1:] From 86ae2de36f56742d70e6caf6c15eda75a168aab6 Mon Sep 17 00:00:00 2001 From: Neha Yadav Date: Wed, 5 Dec 2018 22:22:10 +0530 Subject: [PATCH 004/141] Add verify-boilerplate script --- .travis.yml | 1 + hack/boilerplate/boilerplate.py | 197 ++++++++++++++++++++++++++++ hack/boilerplate/boilerplate.py.txt | 15 +++ hack/boilerplate/boilerplate.sh.txt | 13 ++ hack/verify-boilerplate.sh | 35 +++++ 5 files changed, 261 insertions(+) create mode 100755 hack/boilerplate/boilerplate.py create mode 100644 hack/boilerplate/boilerplate.py.txt create mode 100644 hack/boilerplate/boilerplate.sh.txt create mode 100755 hack/verify-boilerplate.sh diff --git a/.travis.yml b/.travis.yml index c3fefd02..7aa0138b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,4 +31,5 @@ install: script: - ./run_tox.sh tox + - ./hack/verify-boilerplate.sh diff --git a/hack/boilerplate/boilerplate.py b/hack/boilerplate/boilerplate.py new file mode 100755 index 00000000..bdc70c31 --- /dev/null +++ b/hack/boilerplate/boilerplate.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python + +# Copyright 2018 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import print_function + +import argparse +import datetime +import difflib +import glob +import os +import re +import sys + +parser = argparse.ArgumentParser() +parser.add_argument( + "filenames", + help="list of files to check, all files if unspecified", + nargs='*') + +rootdir = os.path.dirname(__file__) + "/../../" +rootdir = os.path.abspath(rootdir) +parser.add_argument( + "--rootdir", default=rootdir, help="root directory to examine") + +default_boilerplate_dir = os.path.join(rootdir, "hack/boilerplate") +parser.add_argument( + "--boilerplate-dir", default=default_boilerplate_dir) + +parser.add_argument( + "-v", "--verbose", + help="give verbose output regarding why a file does not pass", + action="http://github.com/store_true") + +args = parser.parse_args() + +verbose_out = sys.stderr if args.verbose else open("/dev/null", "w") + + +def get_refs(): + refs = {} + + for path in glob.glob(os.path.join(args.boilerplate_dir, "boilerplate.*.txt")): + extension = os.path.basename(path).split(".")[1] + + ref_file = open(path, 'r') + ref = ref_file.read().splitlines() + ref_file.close() + refs[extension] = ref + + return refs + + +def file_passes(filename, refs, regexs): + try: + f = open(filename, 'r') + except Exception as exc: + print("Unable to open %s: %s" % (filename, exc), file=verbose_out) + return False + + data = f.read() + f.close() + + basename = os.path.basename(filename) + extension = file_extension(filename) + + if extension != "": + ref = refs[extension] + else: + ref = refs[basename] + + # remove extra content from the top of files + if extension == "sh": + p = regexs["shebang"] + (data, found) = p.subn("", data, 1) + + data = data.splitlines() + + # if our test file is smaller than the reference it surely fails! + if len(ref) > len(data): + print('File %s smaller than reference (%d < %d)' % + (filename, len(data), len(ref)), + file=verbose_out) + return False + + # trim our file to the same number of lines as the reference file + data = data[:len(ref)] + + p = regexs["year"] + for d in data: + if p.search(d): + print('File %s has the YEAR field, but missing the year of date' % + filename, file=verbose_out) + return False + + # Replace all occurrences of the regex "2014|2015|2016|2017|2018" with "YEAR" + p = regexs["date"] + for i, d in enumerate(data): + (data[i], found) = p.subn('YEAR', d) + if found != 0: + break + + # if we don't match the reference at this point, fail + if ref != data: + print("Header in %s does not match reference, diff:" % + filename, file=verbose_out) + if args.verbose: + print(file=verbose_out) + for line in difflib.unified_diff(ref, data, 'reference', filename, lineterm=''): + print(line, file=verbose_out) + print(file=verbose_out) + return False + + return True + + +def file_extension(filename): + return os.path.splitext(filename)[1].split(".")[-1].lower() + + +# list all the files contain 'DO NOT EDIT', but are not generated +skipped_ungenerated_files = ['hack/boilerplate/boilerplate.py'] + + +def normalize_files(files): + newfiles = [] + for pathname in files: + newfiles.append(pathname) + for i, pathname in enumerate(newfiles): + if not os.path.isabs(pathname): + newfiles[i] = os.path.join(args.rootdir, pathname) + return newfiles + + +def get_files(extensions): + files = [] + if len(args.filenames) > 0: + files = args.filenames + else: + for root, dirs, walkfiles in os.walk(args.rootdir): + for name in walkfiles: + pathname = os.path.join(root, name) + files.append(pathname) + + files = normalize_files(files) + outfiles = [] + for pathname in files: + basename = os.path.basename(pathname) + extension = file_extension(pathname) + if extension in extensions or basename in extensions: + outfiles.append(pathname) + return outfiles + + +def get_dates(): + years = datetime.datetime.now().year + return '(%s)' % '|'.join((str(year) for year in range(2014, years+1))) + + +def get_regexs(): + regexs = {} + # Search for "YEAR" which exists in the boilerplate, but shouldn't in the real thing + regexs["year"] = re.compile('YEAR') + # get_dates return 2014, 2015, 2016, 2017, or 2018 until the current year as a regex like: "(2014|2015|2016|2017|2018)"; + # company holder names can be anything + regexs["date"] = re.compile(get_dates()) + # strip #!.* from shell scripts + regexs["shebang"] = re.compile(r"^(#!.*\n)\n*", re.MULTILINE) + return regexs + + +def main(): + regexs = get_regexs() + refs = get_refs() + filenames = get_files(refs.keys()) + + for filename in filenames: + if not file_passes(filename, refs, regexs): + print(filename, file=sys.stdout) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hack/boilerplate/boilerplate.py.txt b/hack/boilerplate/boilerplate.py.txt new file mode 100644 index 00000000..d781daf9 --- /dev/null +++ b/hack/boilerplate/boilerplate.py.txt @@ -0,0 +1,15 @@ +#!/usr/bin/env python + +# Copyright YEAR The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/hack/boilerplate/boilerplate.sh.txt b/hack/boilerplate/boilerplate.sh.txt new file mode 100644 index 00000000..34cb349c --- /dev/null +++ b/hack/boilerplate/boilerplate.sh.txt @@ -0,0 +1,13 @@ +# Copyright YEAR The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/hack/verify-boilerplate.sh b/hack/verify-boilerplate.sh new file mode 100755 index 00000000..2f54c8cc --- /dev/null +++ b/hack/verify-boilerplate.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# Copyright 2018 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit +set -o nounset +set -o pipefail + +KUBE_ROOT=$(dirname "${BASH_SOURCE}")/.. + +boilerDir="${KUBE_ROOT}/hack/boilerplate" +boiler="${boilerDir}/boilerplate.py" + +files_need_boilerplate=($(${boiler} "$@")) + +# Run boilerplate check +if [[ ${#files_need_boilerplate[@]} -gt 0 ]]; then + for file in "${files_need_boilerplate[@]}"; do + echo "Boilerplate header is wrong for: ${file}" >&2 + done + + exit 1 +fi From d56fdbc0cc33a6c8e4782c93b50c56c889fb3fa3 Mon Sep 17 00:00:00 2001 From: Neha Yadav Date: Wed, 5 Dec 2018 22:22:59 +0530 Subject: [PATCH 005/141] Verify Boilerplate fix --- config/__init__.py | 2 ++ config/config_exception.py | 2 ++ config/dateutil.py | 2 ++ config/dateutil_test.py | 2 ++ config/exec_provider.py | 2 ++ config/exec_provider_test.py | 2 ++ config/incluster_config.py | 2 ++ config/incluster_config_test.py | 2 ++ config/kube_config.py | 2 ++ config/kube_config_test.py | 2 ++ run_tox.sh | 3 +-- stream/__init__.py | 2 ++ stream/stream.py | 20 ++++++++++++-------- stream/ws_client.py | 20 ++++++++++++-------- stream/ws_client_test.py | 4 +++- watch/__init__.py | 2 ++ watch/watch.py | 2 ++ watch/watch_test.py | 2 ++ 18 files changed, 56 insertions(+), 19 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 3476ff71..02a7532d 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/config_exception.py b/config/config_exception.py index 23fab022..9bf049c6 100644 --- a/config/config_exception.py +++ b/config/config_exception.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/dateutil.py b/config/dateutil.py index ed88cba8..402751cd 100644 --- a/config/dateutil.py +++ b/config/dateutil.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2017 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/dateutil_test.py b/config/dateutil_test.py index deb0ea88..7a13fad0 100644 --- a/config/dateutil_test.py +++ b/config/dateutil_test.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/exec_provider.py b/config/exec_provider.py index 436942f0..a4198353 100644 --- a/config/exec_provider.py +++ b/config/exec_provider.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/exec_provider_test.py b/config/exec_provider_test.py index 44579beb..8b6517b0 100644 --- a/config/exec_provider_test.py +++ b/config/exec_provider_test.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/incluster_config.py b/config/incluster_config.py index 60fc0af8..e643f0df 100644 --- a/config/incluster_config.py +++ b/config/incluster_config.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/incluster_config_test.py b/config/incluster_config_test.py index 622b31b3..3cb0abfc 100644 --- a/config/incluster_config_test.py +++ b/config/incluster_config_test.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/kube_config.py b/config/kube_config.py index 958959e3..058ae290 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/kube_config_test.py b/config/kube_config_test.py index ae9dc225..ee4f49d9 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/run_tox.sh b/run_tox.sh index 55733785..4b583924 100755 --- a/run_tox.sh +++ b/run_tox.sh @@ -11,7 +11,7 @@ # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and +# See the License for the specific language governing permissions and # limitations under the License. set -o errexit @@ -51,4 +51,3 @@ git status echo "Running tox from the main repo on $TOXENV environment" # Run the user-provided command. "${@}" - diff --git a/stream/__init__.py b/stream/__init__.py index e72d0583..e9b7d24f 100644 --- a/stream/__init__.py +++ b/stream/__init__.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2017 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/stream/stream.py b/stream/stream.py index 0412fc33..3eab0b9a 100644 --- a/stream/stream.py +++ b/stream/stream.py @@ -1,14 +1,18 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at +#!/usr/bin/env python + +# Copyright 2018 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from . import ws_client diff --git a/stream/ws_client.py b/stream/ws_client.py index 1cc56cdd..c6fea7ba 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -1,14 +1,18 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at +#!/usr/bin/env python + +# Copyright 2018 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from kubernetes.client.rest import ApiException diff --git a/stream/ws_client_test.py b/stream/ws_client_test.py index e2eca96c..756d9597 100644 --- a/stream/ws_client_test.py +++ b/stream/ws_client_test.py @@ -1,4 +1,6 @@ -# Copyright 2017 The Kubernetes Authors. +#!/usr/bin/env python + +# Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/watch/__init__.py b/watch/__init__.py index ca9ac069..46a31ced 100644 --- a/watch/__init__.py +++ b/watch/__init__.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/watch/watch.py b/watch/watch.py index 21899dd8..fb4c1abf 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/watch/watch_test.py b/watch/watch_test.py index d1ec80a1..f2804f4a 100644 --- a/watch/watch_test.py +++ b/watch/watch_test.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); From 375befb15cbbf418468d56554ee4b5de77232f3f Mon Sep 17 00:00:00 2001 From: Neha Yadav Date: Tue, 11 Dec 2018 22:46:45 +0530 Subject: [PATCH 006/141] Make dependancy adal optional --- config/kube_config.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 958959e3..e51697bc 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -21,7 +21,6 @@ import tempfile import time -import adal import google.auth import google.auth.transport.requests import oauthlib.oauth2 @@ -36,6 +35,11 @@ from .config_exception import ConfigException from .dateutil import UTC, format_rfc3339, parse_rfc3339 +try: + import adal +except ImportError: + pass + EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') _temp_files = {} @@ -218,6 +222,9 @@ def _load_azure_token(self, provider): return self.token def _refresh_azure_token(self, config): + if 'adal' not in globals(): + raise ImportError('refresh token error, adal library not imported') + tenant = config['tenant-id'] authority = 'https://login.microsoftonline.com/%7B%7D'.format(tenant) context = adal.AuthenticationContext( From 1637d56364e62cff2d2d188e8a046f9ba77bb763 Mon Sep 17 00:00:00 2001 From: axelsteingrimsson Date: Wed, 12 Dec 2018 12:47:12 +0100 Subject: [PATCH 007/141] Add email scope to GCP provided credential refresh --- config/kube_config.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 958959e3..b391fb22 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -141,9 +141,10 @@ def __init__(self, config_dict, active_context=None, self._config_persister = config_persister def _refresh_credentials(): - credentials, project_id = google.auth.default( - scopes=['https://www.googleapis.com/auth/cloud-platform'] - ) + credentials, project_id = google.auth.default(scopes=[ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email' + ]) request = google.auth.transport.requests.Request() credentials.refresh(request) return credentials From ebb49d02ed90256cd002d1d75cb8a92125c4392e Mon Sep 17 00:00:00 2001 From: Xavier Vello Date: Mon, 7 Jan 2019 18:19:57 +0100 Subject: [PATCH 008/141] Use safe_load and safe_dump for all yaml calls --- config/kube_config.py | 2 +- config/kube_config_test.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 958959e3..300d924e 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -493,7 +493,7 @@ def get_with_name(self, name, safe=False): def _get_kube_config_loader_for_yaml_file(filename, **kwargs): with open(filename) as f: return KubeConfigLoader( - config_dict=yaml.load(f), + config_dict=yaml.safe_load(f), config_base_path=os.path.abspath(os.path.dirname(filename)), **kwargs) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index ae9dc225..f0bddf8b 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -896,14 +896,14 @@ def test_ssl_with_relative_ssl_files(self): def test_load_kube_config(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) - config_file = self._create_temp_file(yaml.dump(self.TEST_KUBE_CONFIG)) + config_file = self._create_temp_file(yaml.safe_dump(self.TEST_KUBE_CONFIG)) actual = FakeConfig() load_kube_config(config_file=config_file, context="simple_token", client_configuration=actual) self.assertEqual(expected, actual) def test_list_kube_config_contexts(self): - config_file = self._create_temp_file(yaml.dump(self.TEST_KUBE_CONFIG)) + config_file = self._create_temp_file(yaml.safe_dump(self.TEST_KUBE_CONFIG)) contexts, active_context = list_kube_config_contexts( config_file=config_file) self.assertDictEqual(self.TEST_KUBE_CONFIG['contexts'][0], @@ -916,7 +916,7 @@ def test_list_kube_config_contexts(self): contexts) def test_new_client_from_config(self): - config_file = self._create_temp_file(yaml.dump(self.TEST_KUBE_CONFIG)) + config_file = self._create_temp_file(yaml.safe_dump(self.TEST_KUBE_CONFIG)) client = new_client_from_config( config_file=config_file, context="simple_token") self.assertEqual(TEST_HOST, client.configuration.host) From 13ff5184ac43c0bffa813bbba4fca04d610c45d7 Mon Sep 17 00:00:00 2001 From: Xavier Vello Date: Tue, 8 Jan 2019 10:37:28 +0100 Subject: [PATCH 009/141] linting --- config/kube_config_test.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index f0bddf8b..37ff3e27 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -896,14 +896,16 @@ def test_ssl_with_relative_ssl_files(self): def test_load_kube_config(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) - config_file = self._create_temp_file(yaml.safe_dump(self.TEST_KUBE_CONFIG)) + config_file = self._create_temp_file( + yaml.safe_dump(self.TEST_KUBE_CONFIG)) actual = FakeConfig() load_kube_config(config_file=config_file, context="simple_token", client_configuration=actual) self.assertEqual(expected, actual) def test_list_kube_config_contexts(self): - config_file = self._create_temp_file(yaml.safe_dump(self.TEST_KUBE_CONFIG)) + config_file = self._create_temp_file( + yaml.safe_dump(self.TEST_KUBE_CONFIG)) contexts, active_context = list_kube_config_contexts( config_file=config_file) self.assertDictEqual(self.TEST_KUBE_CONFIG['contexts'][0], @@ -916,7 +918,8 @@ def test_list_kube_config_contexts(self): contexts) def test_new_client_from_config(self): - config_file = self._create_temp_file(yaml.safe_dump(self.TEST_KUBE_CONFIG)) + config_file = self._create_temp_file( + yaml.safe_dump(self.TEST_KUBE_CONFIG)) client = new_client_from_config( config_file=config_file, context="simple_token") self.assertEqual(TEST_HOST, client.configuration.host) From 3c30a3099336a5976074c18ea61814646689b4a8 Mon Sep 17 00:00:00 2001 From: Julian Taylor Date: Sat, 19 Jan 2019 12:38:57 +0100 Subject: [PATCH 010/141] fix watching with a specified resource version The watch code reset the version to the last found in the response. When you first list existing objects and then start watching from that resource version the existing versions are older than the version you wanted and the watch starts from the wrong version after the first restart. This leads to for example already deleted objects ending in the stream again. Fix this by setting the minimum resource version to reset from to the input resource version. As long as k8s returns all objects in order in the watch this should work. We cannot use the integer value of the resource version to order it as one should be treat the value as opaque. Closes https://github.com/kubernetes-client/python/issues/700 --- watch/watch.py | 2 ++ watch/watch_test.py | 73 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/watch/watch.py b/watch/watch.py index 21899dd8..a9c315cd 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -122,6 +122,8 @@ def stream(self, func, *args, **kwargs): return_type = self.get_return_type(func) kwargs['watch'] = True kwargs['_preload_content'] = False + if 'resource_version' in kwargs: + self.resource_version = kwargs['resource_version'] timeouts = ('timeout_seconds' in kwargs) while True: diff --git a/watch/watch_test.py b/watch/watch_test.py index d1ec80a1..672c0526 100644 --- a/watch/watch_test.py +++ b/watch/watch_test.py @@ -14,12 +14,15 @@ import unittest -from mock import Mock +from mock import Mock, call from .watch import Watch class WatchTests(unittest.TestCase): + def setUp(self): + # counter for a test that needs test global state + self.callcount = 0 def test_watch_with_decode(self): fake_resp = Mock() @@ -62,6 +65,74 @@ def test_watch_with_decode(self): fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() + def test_watch_resource_version_set(self): + # https://github.com/kubernetes-client/python/issues/700 + # ensure watching from a resource version does reset to resource + # version 0 after k8s resets the watch connection + fake_resp = Mock() + fake_resp.close = Mock() + fake_resp.release_conn = Mock() + values = [ + '{"type": "ADDED", "object": {"metadata": {"name": "test1",' + '"resourceVersion": "1"}, "spec": {}, "status": {}}}\n', + '{"type": "ADDED", "object": {"metadata": {"name": "test2",' + '"resourceVersion": "2"}, "spec": {}, "sta', + 'tus": {}}}\n' + '{"type": "ADDED", "object": {"metadata": {"name": "test3",' + '"resourceVersion": "3"}, "spec": {}, "status": {}}}\n' + ] + # return nothing on the first call and values on the second + # this emulates a watch from a rv that returns nothing in the first k8s + # watch reset and values later + + def get_values(*args, **kwargs): + self.callcount += 1 + if self.callcount == 1: + return [] + else: + return values + + fake_resp.read_chunked = Mock( + side_effect=get_values) + + fake_api = Mock() + fake_api.get_namespaces = Mock(return_value=fake_resp) + fake_api.get_namespaces.__doc__ = ':return: V1NamespaceList' + + w = Watch() + # ensure we keep our requested resource version or the version latest + # returned version when the existing versions are older than the + # requested version + # needed for the list existing objects, then watch from there use case + calls = [] + + iterations = 2 + # first two calls must use the passed rv, the first call is a + # "reset" and does not actually return anything + # the second call must use the same rv but will return values + # (with a wrong rv but a real cluster would behave correctly) + # calls following that will use the rv from those returned values + calls.append(call(_preload_content=False, watch=True, + resource_version="5")) + calls.append(call(_preload_content=False, watch=True, + resource_version="5")) + for i in range(iterations): + # ideally we want 5 here but as rv must be treated as an + # opaque value we cannot interpret it and order it so rely + # on k8s returning the events completely and in order + calls.append(call(_preload_content=False, watch=True, + resource_version="3")) + + for c, e in enumerate(w.stream(fake_api.get_namespaces, + resource_version="5")): + if c == len(values) * iterations: + w.stop() + + # check calls are in the list, gives good error output + fake_api.get_namespaces.assert_has_calls(calls) + # more strict test with worse error message + self.assertEqual(fake_api.get_namespaces.mock_calls, calls) + def test_watch_stream_twice(self): w = Watch(float) for step in ['first', 'second']: From 4d387d5879ab280ecf18ffb0b39846b040fd533b Mon Sep 17 00:00:00 2001 From: Roy Lenferink Date: Mon, 4 Feb 2019 19:01:16 +0100 Subject: [PATCH 011/141] Updated OWNERS to include link to docs --- OWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OWNERS b/OWNERS index 7a860ad2..cfec4b11 100644 --- a/OWNERS +++ b/OWNERS @@ -1,3 +1,5 @@ +# See the OWNERS docs at https://go.k8s.io/owners + approvers: - mbohlool - yliaog From 0fc0d404acd4a6080409e2796b7f6d6002039861 Mon Sep 17 00:00:00 2001 From: Neha Yadav Date: Fri, 8 Feb 2019 02:46:07 +0530 Subject: [PATCH 012/141] Update pycodestyle --- config/exec_provider.py | 7 ++++--- config/incluster_config.py | 3 ++- config/kube_config.py | 6 ++++-- hack/boilerplate/boilerplate.py | 14 +++++++++----- stream/ws_client.py | 7 ++++--- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/config/exec_provider.py b/config/exec_provider.py index a4198353..89d81e8c 100644 --- a/config/exec_provider.py +++ b/config/exec_provider.py @@ -23,9 +23,10 @@ class ExecProvider(object): """ - Implementation of the proposal for out-of-tree client authentication providers - as described here -- - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md + Implementation of the proposal for out-of-tree client + authentication providers as described here -- + https://github.com/kubernetes/community/blob/master/contributors + /design-proposals/auth/kubectl-exec-plugins.md Missing from implementation: diff --git a/config/incluster_config.py b/config/incluster_config.py index e643f0df..c9bdc907 100644 --- a/config/incluster_config.py +++ b/config/incluster_config.py @@ -87,7 +87,8 @@ def _set_config(self): def load_incluster_config(): - """Use the service account kubernetes gives to pods to connect to kubernetes + """ + Use the service account kubernetes gives to pods to connect to kubernetes cluster. It's intended for clients that expect to be running inside a pod running on kubernetes. It will raise an exception if called from a process not running in a kubernetes environment.""" diff --git a/config/kube_config.py b/config/kube_config.py index c0e0e26d..743046db 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -556,9 +556,11 @@ def new_client_from_config( config_file=None, context=None, persist_config=True): - """Loads configuration the same as load_kube_config but returns an ApiClient + """ + Loads configuration the same as load_kube_config but returns an ApiClient to be used with any API object. This will allow the caller to concurrently - talk with multiple clusters.""" + talk with multiple clusters. + """ client_config = type.__call__(Configuration) load_kube_config(config_file=config_file, context=context, client_configuration=client_config, diff --git a/hack/boilerplate/boilerplate.py b/hack/boilerplate/boilerplate.py index bdc70c31..61d4cb94 100755 --- a/hack/boilerplate/boilerplate.py +++ b/hack/boilerplate/boilerplate.py @@ -52,7 +52,8 @@ def get_refs(): refs = {} - for path in glob.glob(os.path.join(args.boilerplate_dir, "boilerplate.*.txt")): + for path in glob.glob(os.path.join( + args.boilerplate_dir, "boilerplate.*.txt")): extension = os.path.basename(path).split(".")[1] ref_file = open(path, 'r') @@ -105,7 +106,7 @@ def file_passes(filename, refs, regexs): filename, file=verbose_out) return False - # Replace all occurrences of the regex "2014|2015|2016|2017|2018" with "YEAR" + # Replace all occurrences of regex "2014|2015|2016|2017|2018" with "YEAR" p = regexs["date"] for i, d in enumerate(data): (data[i], found) = p.subn('YEAR', d) @@ -118,7 +119,8 @@ def file_passes(filename, refs, regexs): filename, file=verbose_out) if args.verbose: print(file=verbose_out) - for line in difflib.unified_diff(ref, data, 'reference', filename, lineterm=''): + for line in difflib.unified_diff( + ref, data, 'reference', filename, lineterm=''): print(line, file=verbose_out) print(file=verbose_out) return False @@ -171,9 +173,11 @@ def get_dates(): def get_regexs(): regexs = {} - # Search for "YEAR" which exists in the boilerplate, but shouldn't in the real thing + # Search for "YEAR" which exists in the boilerplate, + # but shouldn't in the real thing regexs["year"] = re.compile('YEAR') - # get_dates return 2014, 2015, 2016, 2017, or 2018 until the current year as a regex like: "(2014|2015|2016|2017|2018)"; + # get_dates return 2014, 2015, 2016, 2017, or 2018 until the current year + # as a regex like: "(2014|2015|2016|2017|2018)"; # company holder names can be anything regexs["date"] = re.compile(get_dates()) # strip #!.* from shell scripts diff --git a/stream/ws_client.py b/stream/ws_client.py index c6fea7ba..cf8a3fe9 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -53,7 +53,8 @@ def __init__(self, configuration, url, headers): header.append("authorization: %s" % headers['authorization']) if headers and 'sec-websocket-protocol' in headers: - header.append("sec-websocket-protocol: %s" % headers['sec-websocket-protocol']) + header.append("sec-websocket-protocol: %s" % + headers['sec-websocket-protocol']) else: header.append("sec-websocket-protocol: v4.channel.k8s.io") @@ -186,8 +187,8 @@ def update(self, timeout=0): data = data[1:] if data: if channel in [STDOUT_CHANNEL, STDERR_CHANNEL]: - # keeping all messages in the order they received for - # non-blocking call. + # keeping all messages in the order they received + # for non-blocking call. self._all += data if channel not in self._channels: self._channels[channel] = data From 0229f0adb26951e82bd9fb3ef7344951c52e4b75 Mon Sep 17 00:00:00 2001 From: micw523 Date: Mon, 11 Feb 2019 17:11:37 -0600 Subject: [PATCH 013/141] Restore one-line link --- config/exec_provider.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/exec_provider.py b/config/exec_provider.py index 89d81e8c..a0348f1e 100644 --- a/config/exec_provider.py +++ b/config/exec_provider.py @@ -25,8 +25,7 @@ class ExecProvider(object): """ Implementation of the proposal for out-of-tree client authentication providers as described here -- - https://github.com/kubernetes/community/blob/master/contributors - /design-proposals/auth/kubectl-exec-plugins.md + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/auth/kubectl-exec-plugins.md Missing from implementation: From 8e6f0435a38e24aac700d9ebac700bdf6138ba8c Mon Sep 17 00:00:00 2001 From: Mitar Date: Mon, 15 Oct 2018 23:57:46 -0700 Subject: [PATCH 014/141] Making watch work with read_namespaced_pod_log. Fixes https://github.com/kubernetes-client/python/issues/199. --- watch/watch.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/watch/watch.py b/watch/watch.py index bdf24f1a..79b2358d 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -20,6 +20,7 @@ from kubernetes import client PYDOC_RETURN_LABEL = ":return:" +PYDOC_FOLLOW_PARAM = ":param bool follow:" # Removing this suffix from return type name should give us event's object # type. e.g., if list_namespaces() returns "NamespaceList" type, @@ -65,7 +66,7 @@ def __init__(self, return_type=None): self._raw_return_type = return_type self._stop = False self._api_client = client.ApiClient() - self.resource_version = 0 + self.resource_version = None def stop(self): self._stop = True @@ -78,8 +79,17 @@ def get_return_type(self, func): return return_type[:-len(TYPE_LIST_SUFFIX)] return return_type + def get_watch_argument_name(self, func): + if PYDOC_FOLLOW_PARAM in pydoc.getdoc(func): + return 'follow' + else: + return 'watch' + def unmarshal_event(self, data, return_type): - js = json.loads(data) + try: + js = json.loads(data) + except ValueError: + return data js['raw_object'] = js['object'] if return_type: obj = SimpleNamespace(data=json.dumps(js['raw_object'])) @@ -122,7 +132,7 @@ def stream(self, func, *args, **kwargs): self._stop = False return_type = self.get_return_type(func) - kwargs['watch'] = True + kwargs[self.get_watch_argument_name(func)] = True kwargs['_preload_content'] = False if 'resource_version' in kwargs: self.resource_version = kwargs['resource_version'] @@ -136,9 +146,12 @@ def stream(self, func, *args, **kwargs): if self._stop: break finally: - kwargs['resource_version'] = self.resource_version resp.close() resp.release_conn() + if self.resource_version is not None: + kwargs['resource_version'] = self.resource_version + else: + break if timeouts or self._stop: break From 4750aa9d3691cd0652654b56f54fb6897001a4a7 Mon Sep 17 00:00:00 2001 From: Ben Picolo Date: Mon, 18 Feb 2019 11:16:07 -0500 Subject: [PATCH 015/141] Add additional checks + test case fixes --- config/kube_config.py | 13 ++++++++++++- config/kube_config_test.py | 24 ++++++++++++++++-------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 3691a18b..b939685e 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -252,12 +252,23 @@ def _load_oid_token(self, provider): if 'config' not in provider: return - parts = provider['config']['id-token'].split('.') + reserved_characters = frozenset(["=", "+", "/"]) + token = provider['config']['id-token'] + if any(char in token for char in reserved_characters): + # Invalid jwt, as it contains url-unsafe chars + return None + + parts = token.split('.') if len(parts) != 3: # Not a valid JWT return None padding = (4 - len(parts[1]) % 4) * '=' + if len(padding) == 3: + # According to spec, 3 padding characters cannot occur + # in a valid jwt + # https://tools.ietf.org/html/rfc7515#appendix-C + return None if PY3: jwt_attributes = json.loads( diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 12d6916d..faa4c417 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -43,8 +43,8 @@ def _base64(string): return base64.encodestring(string.encode()).decode() -def _unpadded_base64(string): - return base64.b64encode(string.encode()).decode().rstrip('=') +def _urlsafe_unpadded_b64encode(string): + return base64.urlsafe_b64encode(string.encode()).decode().rstrip('=') def _format_expiry_datetime(dt): @@ -91,14 +91,22 @@ def _raise_exception(st): TEST_OIDC_TOKEN = "test-oidc-token" TEST_OIDC_INFO = "{\"name\": \"test\"}" -TEST_OIDC_BASE = _unpadded_base64( - TEST_OIDC_TOKEN) + "." + _unpadded_base64(TEST_OIDC_INFO) -TEST_OIDC_LOGIN = TEST_OIDC_BASE + "." + TEST_CLIENT_CERT_BASE64 +TEST_OIDC_BASE = ".".join([ + _urlsafe_unpadded_b64encode(TEST_OIDC_TOKEN), + _urlsafe_unpadded_b64encode(TEST_OIDC_INFO) +]) +TEST_OIDC_LOGIN = ".".join([ + TEST_OIDC_BASE, + _urlsafe_unpadded_b64encode(TEST_CLIENT_CERT_BASE64) +]) TEST_OIDC_TOKEN = "Bearer %s" % TEST_OIDC_LOGIN TEST_OIDC_EXP = "{\"name\": \"test\",\"exp\": 536457600}" -TEST_OIDC_EXP_BASE = _unpadded_base64( - TEST_OIDC_TOKEN) + "." + _unpadded_base64(TEST_OIDC_EXP) -TEST_OIDC_EXPIRED_LOGIN = TEST_OIDC_EXP_BASE + "." + TEST_CLIENT_CERT_BASE64 +TEST_OIDC_EXP_BASE = _urlsafe_unpadded_b64encode( + TEST_OIDC_TOKEN) + "." + _urlsafe_unpadded_b64encode(TEST_OIDC_EXP) +TEST_OIDC_EXPIRED_LOGIN = ".".join([ + TEST_OIDC_EXP_BASE, + _urlsafe_unpadded_b64encode(TEST_CLIENT_CERT) +]) TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH) From ad06e5c923b2d4e5db86f7e91deddb95a6dc9a43 Mon Sep 17 00:00:00 2001 From: Mitar Date: Mon, 18 Feb 2019 16:43:50 -0800 Subject: [PATCH 016/141] Added tests. --- watch/watch_test.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/watch/watch_test.py b/watch/watch_test.py index 08eb36c2..ebc400af 100644 --- a/watch/watch_test.py +++ b/watch/watch_test.py @@ -67,6 +67,35 @@ def test_watch_with_decode(self): fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() + def test_watch_for_follow(self): + fake_resp = Mock() + fake_resp.close = Mock() + fake_resp.release_conn = Mock() + fake_resp.read_chunked = Mock( + return_value=[ + 'log_line_1\n', + 'log_line_2\n']) + + fake_api = Mock() + fake_api.read_namespaced_pod_log = Mock(return_value=fake_resp) + fake_api.read_namespaced_pod_log.__doc__ = ':param bool follow:\n:return: str' + + w = Watch() + count = 1 + for e in w.stream(fake_api.read_namespaced_pod_log): + self.assertEqual("log_line_1", e) + count += 1 + # make sure we can stop the watch and the last event with won't be + # returned + if count == 2: + w.stop() + + fake_api.read_namespaced_pod_log.assert_called_once_with( + _preload_content=False, follow=True) + fake_resp.read_chunked.assert_called_once_with(decode_content=False) + fake_resp.close.assert_called_once() + fake_resp.release_conn.assert_called_once() + def test_watch_resource_version_set(self): # https://github.com/kubernetes-client/python/issues/700 # ensure watching from a resource version does reset to resource From 972a76a83d0133b45db03495b0f9fd05ed2b94a3 Mon Sep 17 00:00:00 2001 From: Mitar Date: Wed, 20 Feb 2019 23:56:38 -0800 Subject: [PATCH 017/141] Don't use break inside finally. It swallows exceptions. --- watch/watch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watch/watch.py b/watch/watch.py index 79b2358d..5966eace 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -151,7 +151,7 @@ def stream(self, func, *args, **kwargs): if self.resource_version is not None: kwargs['resource_version'] = self.resource_version else: - break + self._stop = True if timeouts or self._stop: break From 328b2d12452c9125fa74590e971423970c1d750a Mon Sep 17 00:00:00 2001 From: Tomasz Prus Date: Sat, 20 Oct 2018 00:49:51 +0200 Subject: [PATCH 018/141] feat: merging kubeconfig files --- config/kube_config.py | 134 ++++++++++++++++++++++++------ config/kube_config_test.py | 165 ++++++++++++++++++++++++++++++++++++- 2 files changed, 274 insertions(+), 25 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 300d924e..be6156cb 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -14,10 +14,12 @@ import atexit import base64 +import copy import datetime import json import logging import os +import platform import tempfile import time @@ -38,6 +40,7 @@ EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') +ENV_KUBECONFIG_PATH_SEPARATOR = ';' if platform.system() == 'Windows' else ':' _temp_files = {} @@ -132,7 +135,12 @@ def __init__(self, config_dict, active_context=None, get_google_credentials=None, config_base_path="", config_persister=None): - self._config = ConfigNode('kube-config', config_dict) + + if isinstance(config_dict, ConfigNode): + self._config = config_dict + else: + self._config = ConfigNode('kube-config', config_dict) + self._current_context = None self._user = None self._cluster = None @@ -361,9 +369,10 @@ def _load_from_exec_plugin(self): logging.error(str(e)) def _load_user_token(self): + base_path = self._get_base_path(self._user.path) token = FileOrData( self._user, 'tokenFile', 'token', - file_base_path=self._config_base_path, + file_base_path=base_path, base64_file_content=False).as_data() if token: self.token = "Bearer %s" % token @@ -376,19 +385,27 @@ def _load_user_pass_token(self): self._user['password'])).get('authorization') return True + def _get_base_path(self, config_path): + if self._config_base_path is not None: + return self._config_base_path + if config_path is not None: + return os.path.abspath(os.path.dirname(config_path)) + return "" + def _load_cluster_info(self): if 'server' in self._cluster: self.host = self._cluster['server'].rstrip('/') if self.host.startswith("https"): + base_path = self._get_base_path(self._cluster.path) self.ssl_ca_cert = FileOrData( self._cluster, 'certificate-authority', - file_base_path=self._config_base_path).as_file() + file_base_path=base_path).as_file() self.cert_file = FileOrData( self._user, 'client-certificate', - file_base_path=self._config_base_path).as_file() + file_base_path=base_path).as_file() self.key_file = FileOrData( self._user, 'client-key', - file_base_path=self._config_base_path).as_file() + file_base_path=base_path).as_file() if 'insecure-skip-tls-verify' in self._cluster: self.verify_ssl = not self._cluster['insecure-skip-tls-verify'] @@ -435,9 +452,10 @@ class ConfigNode(object): message in case of missing keys. The assumption is all access keys are present in a well-formed kube-config.""" - def __init__(self, name, value): + def __init__(self, name, value, path=None): self.name = name self.value = value + self.path = path def __contains__(self, key): return key in self.value @@ -457,7 +475,7 @@ def __getitem__(self, key): 'Invalid kube-config file. Expected key %s in %s' % (key, self.name)) if isinstance(v, dict) or isinstance(v, list): - return ConfigNode('%s/%s' % (self.name, key), v) + return ConfigNode('%s/%s' % (self.name, key), v, self.path) else: return v @@ -482,7 +500,12 @@ def get_with_name(self, name, safe=False): 'Expected only one object with name %s in %s list' % (name, self.name)) if result is not None: - return ConfigNode('%s[name=%s]' % (self.name, name), result) + if isinstance(result, ConfigNode): + return result + else: + return ConfigNode( + '%s[name=%s]' % + (self.name, name), result, self.path) if safe: return None raise ConfigException( @@ -490,18 +513,87 @@ def get_with_name(self, name, safe=False): 'Expected object with name %s in %s list' % (name, self.name)) -def _get_kube_config_loader_for_yaml_file(filename, **kwargs): - with open(filename) as f: - return KubeConfigLoader( - config_dict=yaml.safe_load(f), - config_base_path=os.path.abspath(os.path.dirname(filename)), - **kwargs) +class KubeConfigMerger: + + """Reads and merges configuration from one or more kube-config's. + The propery `config` can be passed to the KubeConfigLoader as config_dict. + + It uses a path attribute from ConfigNode to store the path to kubeconfig. + This path is required to load certs from relative paths. + + A method `save_changes` updates changed kubeconfig's (it compares current + state of dicts with). + """ + + def __init__(self, paths): + self.paths = [] + self.config_files = {} + self.config_merged = None + + for path in paths.split(ENV_KUBECONFIG_PATH_SEPARATOR): + if path: + path = os.path.expanduser(path) + if os.path.exists(path): + self.paths.append(path) + self.load_config(path) + self.config_saved = copy.deepcopy(self.config_files) + + @property + def config(self): + return self.config_merged + + def load_config(self, path): + with open(path) as f: + config = yaml.safe_load(f) + + if self.config_merged is None: + config_merged = copy.deepcopy(config) + for item in ('clusters', 'contexts', 'users'): + config_merged[item] = [] + self.config_merged = ConfigNode(path, config_merged, path) + + for item in ('clusters', 'contexts', 'users'): + self._merge(item, config[item], path) + self.config_files[path] = config + + def _merge(self, item, add_cfg, path): + for new_item in add_cfg: + for exists in self.config_merged.value[item]: + if exists['name'] == new_item['name']: + break + else: + self.config_merged.value[item].append(ConfigNode( + '{}/{}'.format(path, new_item), new_item, path)) + + def save_changes(self): + for path in self.paths: + if self.config_saved[path] != self.config_files[path]: + self.save_config(path) + self.config_saved = copy.deepcopy(self.config_files) + + def save_config(self, path): + with open(path, 'w') as f: + yaml.safe_dump(self.config_files[path], f, + default_flow_style=False) + + +def _get_kube_config_loader_for_yaml_file( + filename, persist_config=False, **kwargs): + + kcfg = KubeConfigMerger(filename) + if persist_config and 'config_persister' not in kwargs: + kwargs['config_persister'] = kcfg.save_changes() + + return KubeConfigLoader( + config_dict=kcfg.config, + config_base_path=None, + **kwargs) def list_kube_config_contexts(config_file=None): if config_file is None: - config_file = os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION) + config_file = KUBE_CONFIG_DEFAULT_LOCATION loader = _get_kube_config_loader_for_yaml_file(config_file) return loader.list_contexts(), loader.current_context @@ -523,18 +615,12 @@ def load_kube_config(config_file=None, context=None, """ if config_file is None: - config_file = os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION) - - config_persister = None - if persist_config: - def _save_kube_config(config_map): - with open(config_file, 'w') as f: - yaml.safe_dump(config_map, f, default_flow_style=False) - config_persister = _save_kube_config + config_file = KUBE_CONFIG_DEFAULT_LOCATION loader = _get_kube_config_loader_for_yaml_file( config_file, active_context=context, - config_persister=config_persister) + persist_config=persist_config) + if client_configuration is None: config = type.__call__(Configuration) loader.load_and_set(config) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 37ff3e27..dc783c21 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -27,7 +27,8 @@ from kubernetes.client import Configuration from .config_exception import ConfigException -from .kube_config import (ConfigNode, FileOrData, KubeConfigLoader, +from .kube_config import (ENV_KUBECONFIG_PATH_SEPARATOR, ConfigNode, + FileOrData, KubeConfigLoader, KubeConfigMerger, _cleanup_temp_files, _create_temp_file_with_content, list_kube_config_contexts, load_kube_config, new_client_from_config) @@ -987,5 +988,167 @@ def fake_get_api_key_with_prefix(identifier): config.auth_settings()['BearerToken']['value']) +class TestKubeConfigMerger(BaseTestCase): + TEST_KUBE_CONFIG_PART1 = { + "current-context": "no_user", + "contexts": [ + { + "name": "no_user", + "context": { + "cluster": "default" + } + }, + ], + "clusters": [ + { + "name": "default", + "cluster": { + "server": TEST_HOST + } + }, + ], + "users": [] + } + + TEST_KUBE_CONFIG_PART2 = { + "current-context": "", + "contexts": [ + { + "name": "ssl", + "context": { + "cluster": "ssl", + "user": "ssl" + } + }, + { + "name": "simple_token", + "context": { + "cluster": "default", + "user": "simple_token" + } + }, + ], + "clusters": [ + { + "name": "ssl", + "cluster": { + "server": TEST_SSL_HOST, + "certificate-authority-data": + TEST_CERTIFICATE_AUTH_BASE64, + } + }, + ], + "users": [ + { + "name": "ssl", + "user": { + "token": TEST_DATA_BASE64, + "client-certificate-data": TEST_CLIENT_CERT_BASE64, + "client-key-data": TEST_CLIENT_KEY_BASE64, + } + }, + ] + } + + TEST_KUBE_CONFIG_PART3 = { + "current-context": "no_user", + "contexts": [ + { + "name": "expired_oidc", + "context": { + "cluster": "default", + "user": "expired_oidc" + } + }, + { + "name": "ssl", + "context": { + "cluster": "skipped-part2-defined-this-context", + "user": "skipped" + } + }, + ], + "clusters": [ + ], + "users": [ + { + "name": "expired_oidc", + "user": { + "auth-provider": { + "name": "oidc", + "config": { + "client-id": "tectonic-kubectl", + "client-secret": "FAKE_SECRET", + "id-token": TEST_OIDC_EXPIRED_LOGIN, + "idp-certificate-authority-data": TEST_OIDC_CA, + "idp-issuer-url": "https://example.org/identity", + "refresh-token": + "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk" + } + } + } + }, + { + "name": "simple_token", + "user": { + "token": TEST_DATA_BASE64, + "username": TEST_USERNAME, # should be ignored + "password": TEST_PASSWORD, # should be ignored + } + }, + ] + } + + def _create_multi_config(self): + files = [] + for part in ( + self.TEST_KUBE_CONFIG_PART1, + self.TEST_KUBE_CONFIG_PART2, + self.TEST_KUBE_CONFIG_PART3): + files.append(self._create_temp_file(yaml.safe_dump(part))) + return ENV_KUBECONFIG_PATH_SEPARATOR.join(files) + + def test_list_kube_config_contexts(self): + kubeconfigs = self._create_multi_config() + expected_contexts = [ + {'context': {'cluster': 'default'}, 'name': 'no_user'}, + {'context': {'cluster': 'ssl', 'user': 'ssl'}, 'name': 'ssl'}, + {'context': {'cluster': 'default', 'user': 'simple_token'}, + 'name': 'simple_token'}, + {'context': {'cluster': 'default', 'user': 'expired_oidc'}, 'name': 'expired_oidc'}] + + contexts, active_context = list_kube_config_contexts( + config_file=kubeconfigs) + + self.assertEqual(contexts, expected_contexts) + self.assertEqual(active_context, expected_contexts[0]) + + def test_new_client_from_config(self): + kubeconfigs = self._create_multi_config() + client = new_client_from_config( + config_file=kubeconfigs, context="simple_token") + self.assertEqual(TEST_HOST, client.configuration.host) + self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, + client.configuration.api_key['authorization']) + + def test_save_changes(self): + kubeconfigs = self._create_multi_config() + + # load configuration, update token, save config + kconf = KubeConfigMerger(kubeconfigs) + user = kconf.config['users'].get_with_name('expired_oidc')['user'] + provider = user['auth-provider']['config'] + provider.value['id-token'] = "token-changed" + kconf.save_changes() + + # re-read configuration + kconf = KubeConfigMerger(kubeconfigs) + user = kconf.config['users'].get_with_name('expired_oidc')['user'] + provider = user['auth-provider']['config'] + + # new token + self.assertEqual(provider.value['id-token'], "token-changed") + + if __name__ == '__main__': unittest.main() From b3ddbd903a45d24091c56060bae3bc9fe74f4e6d Mon Sep 17 00:00:00 2001 From: Ben Picolo Date: Tue, 19 Feb 2019 18:28:50 -0500 Subject: [PATCH 019/141] Add tests for updated pieces --- config/kube_config.py | 6 +-- config/kube_config_test.py | 79 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index b939685e..9f9dcf8a 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -257,18 +257,18 @@ def _load_oid_token(self, provider): if any(char in token for char in reserved_characters): # Invalid jwt, as it contains url-unsafe chars - return None + return parts = token.split('.') if len(parts) != 3: # Not a valid JWT - return None + return padding = (4 - len(parts[1]) % 4) * '=' if len(padding) == 3: # According to spec, 3 padding characters cannot occur # in a valid jwt # https://tools.ietf.org/html/rfc7515#appendix-C - return None + return if PY3: jwt_attributes = json.loads( diff --git a/config/kube_config_test.py b/config/kube_config_test.py index faa4c417..4ddc6f35 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -107,6 +107,17 @@ def _raise_exception(st): TEST_OIDC_EXP_BASE, _urlsafe_unpadded_b64encode(TEST_CLIENT_CERT) ]) +TEST_OIDC_CONTAINS_RESERVED_CHARACTERS = ".".join([ + _urlsafe_unpadded_b64encode(TEST_OIDC_TOKEN), + _urlsafe_unpadded_b64encode(TEST_OIDC_INFO).replace("a", "+"), + _urlsafe_unpadded_b64encode(TEST_CLIENT_CERT) +]) +TEST_OIDC_INVALID_PADDING_LENGTH = ".".join([ + _urlsafe_unpadded_b64encode(TEST_OIDC_TOKEN), + "aaaaa", + _urlsafe_unpadded_b64encode(TEST_CLIENT_CERT) +]) + TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH) @@ -394,6 +405,22 @@ class TestKubeConfigLoader(BaseTestCase): "user": "expired_oidc_nocert" } }, + { + "name": "oidc_contains_reserved_character", + "context": { + "cluster": "default", + "user": "oidc_contains_reserved_character" + + } + }, + { + "name": "oidc_invalid_padding_length", + "context": { + "cluster": "default", + "user": "oidc_invalid_padding_length" + + } + }, { "name": "user_pass", "context": { @@ -556,6 +583,38 @@ class TestKubeConfigLoader(BaseTestCase): } } }, + { + "name": "oidc_contains_reserved_character", + "user": { + "auth-provider": { + "name": "oidc", + "config": { + "client-id": "tectonic-kubectl", + "client-secret": "FAKE_SECRET", + "id-token": TEST_OIDC_CONTAINS_RESERVED_CHARACTERS, + "idp-issuer-url": "https://example.org/identity", + "refresh-token": + "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk" + } + } + } + }, + { + "name": "oidc_invalid_padding_length", + "user": { + "auth-provider": { + "name": "oidc", + "config": { + "client-id": "tectonic-kubectl", + "client-secret": "FAKE_SECRET", + "id-token": TEST_OIDC_INVALID_PADDING_LENGTH, + "idp-issuer-url": "https://example.org/identity", + "refresh-token": + "lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk" + } + } + } + }, { "name": "user_pass", "user": { @@ -712,6 +771,26 @@ def test_oidc_with_refresh_nocert( self.assertTrue(loader._load_auth_provider_token()) self.assertEqual("Bearer abc123", loader.token) + def test_oidc_fails_if_contains_reserved_chars(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="oidc_contains_reserved_character", + ) + self.assertEqual( + loader._load_oid_token("oidc_contains_reserved_character"), + None, + ) + + def test_oidc_fails_if_invalid_padding_length(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="oidc_invalid_padding_length", + ) + self.assertEqual( + loader._load_oid_token("oidc_invalid_padding_length"), + None, + ) + def test_user_pass(self): expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN) actual = FakeConfig() From 511243a341bdafca9287a618bbc707df7ce19251 Mon Sep 17 00:00:00 2001 From: Oz N Tiram Date: Fri, 5 Apr 2019 21:04:13 +0200 Subject: [PATCH 020/141] Drop Python3.4 Python 3.4 has become EOL on March 18, 2019. https://www.python.org/dev/peps/pep-0429/ --- .travis.yml | 2 -- tox.ini | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index aa1d1d3c..62119655 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,8 +15,6 @@ matrix: env: TOXENV=docs - python: 2.7 env: TOXENV=coverage,codecov - - python: 3.4 - env: TOXENV=py34 - python: 3.5 env: TOXENV=py35 - python: 3.5 diff --git a/tox.ini b/tox.ini index f935a6cd..803390f2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] skipsdist = True -envlist = py27, py34, py35, py36, py37 +envlist = py27, py35, py36, py37 [testenv] passenv = TOXENV CI TRAVIS TRAVIS_* From 30bc4252711928b86bc0d3068c5beb80cc443bc3 Mon Sep 17 00:00:00 2001 From: Roger Hoem-Martinsen Date: Wed, 3 Jul 2019 09:14:03 +0200 Subject: [PATCH 021/141] fix bug and add method azure is expired --- config/kube_config.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 8d36197e..d3c9d51b 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -221,13 +221,20 @@ def _load_auth_provider_token(self): if provider['name'] == 'oidc': return self._load_oid_token(provider) + def _azure_is_expired(self, provider): + expires_on = provider['config']['expires-on'] + if expires_on.isdigit(): + return int(expires_on) < time.time() + else: + return time.strptime(expires_on, '%Y-%m-%d %H:%M:%S.%f') < time.gmtime() + def _load_azure_token(self, provider): if 'config' not in provider: return if 'access-token' not in provider['config']: return if 'expires-on' in provider['config']: - if int(provider['config']['expires-on']) < time.gmtime(): + if self._azure_is_expired(provider): self._refresh_azure_token(provider['config']) self.token = 'Bearer %s' % provider['config']['access-token'] return self.token From 26e16d0c70e61efd73e91367a2ff8208a160964c Mon Sep 17 00:00:00 2001 From: Roger Hoem-Martinsen Date: Wed, 3 Jul 2019 09:14:38 +0200 Subject: [PATCH 022/141] Add azure config tests --- config/kube_config_test.py | 162 +++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index d89a2a50..04f6b11e 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -130,6 +130,10 @@ def _raise_exception(st): TEST_OIDC_CA = _base64(TEST_CERTIFICATE_AUTH) +TEST_AZURE_LOGIN = TEST_OIDC_LOGIN +TEST_AZURE_TOKEN = "test-azure-token" +TEST_AZURE_TOKEN_FULL = "Bearer " + TEST_AZURE_TOKEN + class BaseTestCase(unittest.TestCase): @@ -420,6 +424,41 @@ class TestKubeConfigLoader(BaseTestCase): "user": "oidc" } }, + { + "name": "azure", + "context": { + "cluster": "default", + "user": "azure" + } + }, + { + "name": "azure_num", + "context": { + "cluster": "default", + "user": "azure_num" + } + }, + { + "name": "azure_str", + "context": { + "cluster": "default", + "user": "azure_str" + } + }, + { + "name": "azure_num_error", + "context": { + "cluster": "default", + "user": "azure_str_error" + } + }, + { + "name": "azure_str_error", + "context": { + "cluster": "default", + "user": "azure_str_error" + } + }, { "name": "expired_oidc", "context": { @@ -603,6 +642,89 @@ class TestKubeConfigLoader(BaseTestCase): } } }, + { + "name": "azure", + "user": { + "auth-provider": { + "config": { + "access-token": TEST_AZURE_TOKEN, + "apiserver-id": "ApiserverId", + "environment": "AzurePublicCloud", + "refresh-token": "refreshToken", + "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" + }, + "name": "azure" + } + } + }, + { + "name": "azure_num", + "user": { + "auth-provider": { + "config": { + "access-token": TEST_AZURE_TOKEN, + "apiserver-id": "ApiserverId", + "environment": "AzurePublicCloud", + "expires-in": "0", + "expires-on": "156207275", + "refresh-token": "refreshToken", + "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" + }, + "name": "azure" + } + } + }, + { + "name": "azure_str", + "user": { + "auth-provider": { + "config": { + "access-token": TEST_AZURE_TOKEN, + "apiserver-id": "ApiserverId", + "environment": "AzurePublicCloud", + "expires-in": "0", + "expires-on": "2018-10-18 00:52:29.044727", + "refresh-token": "refreshToken", + "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" + }, + "name": "azure" + } + } + }, + { + "name": "azure_str_error", + "user": { + "auth-provider": { + "config": { + "access-token": TEST_AZURE_TOKEN, + "apiserver-id": "ApiserverId", + "environment": "AzurePublicCloud", + "expires-in": "0", + "expires-on": "2018-10-18 00:52", + "refresh-token": "refreshToken", + "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" + }, + "name": "azure" + } + } + }, + { + "name": "azure_num_error", + "user": { + "auth-provider": { + "config": { + "access-token": TEST_AZURE_TOKEN, + "apiserver-id": "ApiserverId", + "environment": "AzurePublicCloud", + "expires-in": "0", + "expires-on": "-1", + "refresh-token": "refreshToken", + "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" + }, + "name": "azure" + } + } + }, { "name": "expired_oidc", "user": { @@ -886,6 +1008,46 @@ def test_oidc_fails_if_invalid_padding_length(self): None, ) + def test_azure_no_refresh(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="azure", + ) + self.assertTrue(loader._load_auth_provider_token()) + self.assertEqual(TEST_AZURE_TOKEN_FULL, loader.token) + + def test_azure_with_expired_num(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="azure_num", + ) + provider = loader._user['auth-provider'] + self.assertTrue(loader._azure_is_expired(provider)) + + def test_azure_with_expired_str(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="azure_str", + ) + provider = loader._user['auth-provider'] + self.assertTrue(loader._azure_is_expired(provider)) + + def test_azure_with_expired_str_error(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="azure_str_error", + ) + provider = loader._user['auth-provider'] + self.assertRaises(ValueError, loader._azure_is_expired, provider) + + def test_azure_with_expired_int_error(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="azure_num_error", + ) + provider = loader._user['auth-provider'] + self.assertRaises(ValueError, loader._azure_is_expired, provider) + def test_user_pass(self): expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN) actual = FakeConfig() From 6edea7b245599d766c04842fa7db14eb46a66e12 Mon Sep 17 00:00:00 2001 From: Roger Hoem-Martinsen Date: Wed, 3 Jul 2019 09:29:31 +0200 Subject: [PATCH 023/141] shorten down long line --- config/kube_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index d3c9d51b..386b82c1 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -226,7 +226,8 @@ def _azure_is_expired(self, provider): if expires_on.isdigit(): return int(expires_on) < time.time() else: - return time.strptime(expires_on, '%Y-%m-%d %H:%M:%S.%f') < time.gmtime() + exp_time = time.strptime(expires_on, '%Y-%m-%d %H:%M:%S.%f') + return exp_time < time.gmtime() def _load_azure_token(self, provider): if 'config' not in provider: From 7d98f28b68df7a2dfef99ff1fb260afda0e63d72 Mon Sep 17 00:00:00 2001 From: Fabian von Feilitzsch Date: Wed, 24 Jul 2019 13:22:06 -0400 Subject: [PATCH 024/141] Generate docs in python3 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 62119655..3acfcea3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ matrix: env: TOXENV=py27-functional - python: 2.7 env: TOXENV=update-pycodestyle - - python: 2.7 + - python: 3.7 env: TOXENV=docs - python: 2.7 env: TOXENV=coverage,codecov From e1f0bed5c0d5e212c28f2c73fd6584fbdb6d5c21 Mon Sep 17 00:00:00 2001 From: micw523 Date: Tue, 30 Jul 2019 17:11:45 -0400 Subject: [PATCH 025/141] Remove sudo for Travis CI --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3acfcea3..70b1166a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ # ref: https://docs.travis-ci.com/user/languages/python language: python dist: xenial -sudo: required matrix: include: From ca007f3672ddea6b329b2d8a5d34529905f82943 Mon Sep 17 00:00:00 2001 From: Richard Godden <7768980+goddenrich@users.noreply.github.com> Date: Thu, 1 Aug 2019 21:49:32 +0100 Subject: [PATCH 026/141] parse microseconds --- config/dateutil.py | 8 ++++++-- config/dateutil_test.py | 31 +++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/config/dateutil.py b/config/dateutil.py index 402751cd..db880efa 100644 --- a/config/dateutil.py +++ b/config/dateutil.py @@ -46,6 +46,8 @@ def dst(self, dt): re.VERBOSE + re.IGNORECASE) _re_timezone = re.compile(r"([-+])(\d\d?):?(\d\d)?") +MICROSEC_PER_SEC = 1000000 + def parse_rfc3339(s): if isinstance(s, datetime.datetime): @@ -57,8 +59,10 @@ def parse_rfc3339(s): dt = [0] * 7 for x in range(6): dt[x] = int(groups[x]) + us = 0 if groups[6] is not None: - dt[6] = int(groups[6]) + partial_sec = float(groups[6].replace(",", ".")) + us = int(MICROSEC_PER_SEC * partial_sec) tz = UTC if groups[7] is not None and groups[7] != 'Z' and groups[7] != 'z': tz_groups = _re_timezone.search(groups[7]).groups() @@ -72,7 +76,7 @@ def parse_rfc3339(s): return datetime.datetime( year=dt[0], month=dt[1], day=dt[2], hour=dt[3], minute=dt[4], second=dt[5], - microsecond=dt[6], tzinfo=tz) + microsecond=us, tzinfo=tz) def format_rfc3339(date_time): diff --git a/config/dateutil_test.py b/config/dateutil_test.py index 7a13fad0..f5587d6e 100644 --- a/config/dateutil_test.py +++ b/config/dateutil_test.py @@ -22,24 +22,39 @@ class DateUtilTest(unittest.TestCase): - def _parse_rfc3339_test(self, st, y, m, d, h, mn, s): + def _parse_rfc3339_test(self, st, y, m, d, h, mn, s, us): actual = parse_rfc3339(st) - expected = datetime(y, m, d, h, mn, s, 0, UTC) + expected = datetime(y, m, d, h, mn, s, us, UTC) self.assertEqual(expected, actual) def test_parse_rfc3339(self): self._parse_rfc3339_test("2017-07-25T04:44:21Z", - 2017, 7, 25, 4, 44, 21) + 2017, 7, 25, 4, 44, 21, 0) self._parse_rfc3339_test("2017-07-25 04:44:21Z", - 2017, 7, 25, 4, 44, 21) + 2017, 7, 25, 4, 44, 21, 0) self._parse_rfc3339_test("2017-07-25T04:44:21", - 2017, 7, 25, 4, 44, 21) + 2017, 7, 25, 4, 44, 21, 0) self._parse_rfc3339_test("2017-07-25T04:44:21z", - 2017, 7, 25, 4, 44, 21) + 2017, 7, 25, 4, 44, 21, 0) self._parse_rfc3339_test("2017-07-25T04:44:21+03:00", - 2017, 7, 25, 1, 44, 21) + 2017, 7, 25, 1, 44, 21, 0) self._parse_rfc3339_test("2017-07-25T04:44:21-03:00", - 2017, 7, 25, 7, 44, 21) + 2017, 7, 25, 7, 44, 21, 0) + + self._parse_rfc3339_test("2017-07-25T04:44:21,005Z", + 2017, 7, 25, 4, 44, 21, 5000) + self._parse_rfc3339_test("2017-07-25T04:44:21.005Z", + 2017, 7, 25, 4, 44, 21, 5000) + self._parse_rfc3339_test("2017-07-25 04:44:21.0050Z", + 2017, 7, 25, 4, 44, 21, 5000) + self._parse_rfc3339_test("2017-07-25T04:44:21.5", + 2017, 7, 25, 4, 44, 21, 500000) + self._parse_rfc3339_test("2017-07-25T04:44:21.005z", + 2017, 7, 25, 4, 44, 21, 5000) + self._parse_rfc3339_test("2017-07-25T04:44:21.005+03:00", + 2017, 7, 25, 1, 44, 21, 5000) + self._parse_rfc3339_test("2017-07-25T04:44:21.005-03:00", + 2017, 7, 25, 7, 44, 21, 5000) def test_format_rfc3339(self): self.assertEqual( From 461a3bf7ceb688c1c90bce58b51369d05c8d7255 Mon Sep 17 00:00:00 2001 From: Fabian von Feilitzsch Date: Mon, 30 Jul 2018 13:35:47 -0400 Subject: [PATCH 027/141] Add dynamic client --- dynamic/__init__.py | 1 + dynamic/client.py | 268 ++++++++++++++++++++++++++++ dynamic/discovery.py | 404 ++++++++++++++++++++++++++++++++++++++++++ dynamic/exceptions.py | 96 ++++++++++ dynamic/resource.py | 371 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 1140 insertions(+) create mode 100644 dynamic/__init__.py create mode 100644 dynamic/client.py create mode 100644 dynamic/discovery.py create mode 100644 dynamic/exceptions.py create mode 100644 dynamic/resource.py diff --git a/dynamic/__init__.py b/dynamic/__init__.py new file mode 100644 index 00000000..b5df5482 --- /dev/null +++ b/dynamic/__init__.py @@ -0,0 +1 @@ +from .client import * # NOQA diff --git a/dynamic/client.py b/dynamic/client.py new file mode 100644 index 00000000..ba05d36b --- /dev/null +++ b/dynamic/client.py @@ -0,0 +1,268 @@ +import six +import json + +from kubernetes import watch +from kubernetes.client.rest import ApiException + +from .discovery import EagerDiscoverer, LazyDiscoverer +from .exceptions import api_exception, KubernetesValidateMissing +from .resource import Resource, ResourceList, Subresource, ResourceInstance, ResourceField + +try: + import kubernetes_validate + HAS_KUBERNETES_VALIDATE = True +except ImportError: + HAS_KUBERNETES_VALIDATE = False + +try: + from kubernetes_validate.utils import VersionNotSupportedError +except ImportError: + class VersionNotSupportedError(NotImplementedError): + pass + +__all__ = [ + 'DynamicClient', + 'ResourceInstance', + 'Resource', + 'ResourceList', + 'Subresource', + 'EagerDiscoverer', + 'LazyDiscoverer', + 'ResourceField', +] + + +def meta_request(func): + """ Handles parsing response structure and translating API Exceptions """ + def inner(self, *args, **kwargs): + serialize_response = kwargs.pop('serialize', True) + serializer = kwargs.pop('serializer', ResourceInstance) + try: + resp = func(self, *args, **kwargs) + except ApiException as e: + raise api_exception(e) + if serialize_response: + try: + if six.PY2: + return serializer(self, json.loads(resp.data)) + return serializer(self, json.loads(resp.data.decode('utf8'))) + except ValueError: + if six.PY2: + return resp.data + return resp.data.decode('utf8') + return resp + + return inner + + +class DynamicClient(object): + """ A kubernetes client that dynamically discovers and interacts with + the kubernetes API + """ + + def __init__(self, client, cache_file=None, discoverer=None): + # Setting default here to delay evaluation of LazyDiscoverer class + # until constructor is called + discoverer = discoverer or LazyDiscoverer + + self.client = client + self.configuration = client.configuration + self.__discoverer = discoverer(self, cache_file) + + @property + def resources(self): + return self.__discoverer + + @property + def version(self): + return self.__discoverer.version + + def ensure_namespace(self, resource, namespace, body): + namespace = namespace or body.get('metadata', {}).get('namespace') + if not namespace: + raise ValueError("Namespace is required for {}.{}".format(resource.group_version, resource.kind)) + return namespace + + def serialize_body(self, body): + if hasattr(body, 'to_dict'): + return body.to_dict() + return body or {} + + def get(self, resource, name=None, namespace=None, **kwargs): + path = resource.path(name=name, namespace=namespace) + return self.request('get', path, **kwargs) + + def create(self, resource, body=None, namespace=None, **kwargs): + body = self.serialize_body(body) + if resource.namespaced: + namespace = self.ensure_namespace(resource, namespace, body) + path = resource.path(namespace=namespace) + return self.request('post', path, body=body, **kwargs) + + def delete(self, resource, name=None, namespace=None, body=None, label_selector=None, field_selector=None, **kwargs): + if not (name or label_selector or field_selector): + raise ValueError("At least one of name|label_selector|field_selector is required") + if resource.namespaced and not (label_selector or field_selector or namespace): + raise ValueError("At least one of namespace|label_selector|field_selector is required") + path = resource.path(name=name, namespace=namespace) + return self.request('delete', path, body=body, label_selector=label_selector, field_selector=field_selector, **kwargs) + + def replace(self, resource, body=None, name=None, namespace=None, **kwargs): + body = self.serialize_body(body) + name = name or body.get('metadata', {}).get('name') + if not name: + raise ValueError("name is required to replace {}.{}".format(resource.group_version, resource.kind)) + if resource.namespaced: + namespace = self.ensure_namespace(resource, namespace, body) + path = resource.path(name=name, namespace=namespace) + return self.request('put', path, body=body, **kwargs) + + def patch(self, resource, body=None, name=None, namespace=None, **kwargs): + body = self.serialize_body(body) + name = name or body.get('metadata', {}).get('name') + if not name: + raise ValueError("name is required to patch {}.{}".format(resource.group_version, resource.kind)) + if resource.namespaced: + namespace = self.ensure_namespace(resource, namespace, body) + + content_type = kwargs.pop('content_type', 'application/strategic-merge-patch+json') + path = resource.path(name=name, namespace=namespace) + + return self.request('patch', path, body=body, content_type=content_type, **kwargs) + + def watch(self, resource, namespace=None, name=None, label_selector=None, field_selector=None, resource_version=None, timeout=None): + """ + Stream events for a resource from the Kubernetes API + + :param resource: The API resource object that will be used to query the API + :param namespace: The namespace to query + :param name: The name of the resource instance to query + :param label_selector: The label selector with which to filter results + :param field_selector: The field selector with which to filter results + :param resource_version: The version with which to filter results. Only events with + a resource_version greater than this value will be returned + :param timeout: The amount of time in seconds to wait before terminating the stream + + :return: Event object with these keys: + 'type': The type of event such as "ADDED", "DELETED", etc. + 'raw_object': a dict representing the watched object. + 'object': A ResourceInstance wrapping raw_object. + + Example: + client = DynamicClient(k8s_client) + v1_pods = client.resources.get(api_version='v1', kind='Pod') + + for e in v1_pods.watch(resource_version=0, namespace=default, timeout=5): + print(e['type']) + print(e['object'].metadata) + """ + watcher = watch.Watch() + for event in watcher.stream( + resource.get, + namespace=namespace, + name=name, + field_selector=field_selector, + label_selector=label_selector, + resource_version=resource_version, + serialize=False, + timeout_seconds=timeout + ): + event['object'] = ResourceInstance(resource, event['object']) + yield event + + @meta_request + def request(self, method, path, body=None, **params): + if not path.startswith('/'): + path = '/' + path + + path_params = params.get('path_params', {}) + query_params = params.get('query_params', []) + if params.get('pretty') is not None: + query_params.append(('pretty', params['pretty'])) + if params.get('_continue') is not None: + query_params.append(('continue', params['_continue'])) + if params.get('include_uninitialized') is not None: + query_params.append(('includeUninitialized', params['include_uninitialized'])) + if params.get('field_selector') is not None: + query_params.append(('fieldSelector', params['field_selector'])) + if params.get('label_selector') is not None: + query_params.append(('labelSelector', params['label_selector'])) + if params.get('limit') is not None: + query_params.append(('limit', params['limit'])) + if params.get('resource_version') is not None: + query_params.append(('resourceVersion', params['resource_version'])) + if params.get('timeout_seconds') is not None: + query_params.append(('timeoutSeconds', params['timeout_seconds'])) + if params.get('watch') is not None: + query_params.append(('watch', params['watch'])) + if params.get('grace_period_seconds') is not None: + query_params.append(('gracePeriodSeconds', params['grace_period_seconds'])) + if params.get('propagation_policy') is not None: + query_params.append(('propagationPolicy', params['propagation_policy'])) + if params.get('orphan_dependents') is not None: + query_params.append(('orphanDependents', params['orphan_dependents'])) + + header_params = params.get('header_params', {}) + form_params = [] + local_var_files = {} + # HTTP header `Accept` + header_params['Accept'] = self.client.select_header_accept([ + 'application/json', + 'application/yaml', + ]) + + # HTTP header `Content-Type` + if params.get('content_type'): + header_params['Content-Type'] = params['content_type'] + else: + header_params['Content-Type'] = self.client.select_header_content_type(['*/*']) + + # Authentication setting + auth_settings = ['BearerToken'] + + return self.client.call_api( + path, + method.upper(), + path_params, + query_params, + header_params, + body=body, + post_params=form_params, + async_req=params.get('async_req'), + files=local_var_files, + auth_settings=auth_settings, + _preload_content=False, + _return_http_data_only=params.get('_return_http_data_only', True) + ) + + def validate(self, definition, version=None, strict=False): + """validate checks a kubernetes resource definition + + Args: + definition (dict): resource definition + version (str): version of kubernetes to validate against + strict (bool): whether unexpected additional properties should be considered errors + + Returns: + warnings (list), errors (list): warnings are missing validations, errors are validation failures + """ + if not HAS_KUBERNETES_VALIDATE: + raise KubernetesValidateMissing() + + errors = list() + warnings = list() + try: + if version is None: + try: + version = self.version['kubernetes']['gitVersion'] + except KeyError: + version = kubernetes_validate.latest_version() + kubernetes_validate.validate(definition, version, strict) + except kubernetes_validate.utils.ValidationError as e: + errors.append("resource definition validation error at %s: %s" % ('.'.join([str(item) for item in e.path]), e.message)) # noqa: B306 + except VersionNotSupportedError: + errors.append("Kubernetes version %s is not supported by kubernetes-validate" % version) + except kubernetes_validate.utils.SchemaNotFoundError as e: + warnings.append("Could not find schema for object kind %s with API version %s in Kubernetes version %s (possibly Custom Resource?)" % + (e.kind, e.api_version, e.version)) + return warnings, errors diff --git a/dynamic/discovery.py b/dynamic/discovery.py new file mode 100644 index 00000000..d0011322 --- /dev/null +++ b/dynamic/discovery.py @@ -0,0 +1,404 @@ +import os +import six +import json +import hashlib +import tempfile +from collections import defaultdict +from abc import abstractmethod, abstractproperty + +from urllib3.exceptions import ProtocolError, MaxRetryError + +from kubernetes import __version__ +from .exceptions import NotFoundError, ResourceNotFoundError, ResourceNotUniqueError, ApiException +from .resource import Resource, ResourceList + + +DISCOVERY_PREFIX = 'apis' + + +class Discoverer(object): + """ + A convenient container for storing discovered API resources. Allows + easy searching and retrieval of specific resources. + + Subclasses implement the abstract methods with different loading strategies. + """ + + def __init__(self, client, cache_file): + self.client = client + default_cache_id = self.client.configuration.host + if six.PY3: + default_cache_id = default_cache_id.encode('utf-8') + default_cachefile_name = 'osrcp-{0}.json'.format(hashlib.md5(default_cache_id).hexdigest()) + self.__cache_file = cache_file or os.path.join(tempfile.gettempdir(), default_cachefile_name) + self.__init_cache() + + def __init_cache(self, refresh=False): + if refresh or not os.path.exists(self.__cache_file): + self._cache = {'library_version': __version__} + refresh = True + else: + try: + with open(self.__cache_file, 'r') as f: + self._cache = json.load(f, cls=CacheDecoder(self.client)) + if self._cache.get('library_version') != __version__: + # Version mismatch, need to refresh cache + self.invalidate_cache() + except Exception: + self.invalidate_cache() + self._load_server_info() + self.discover() + if refresh: + self._write_cache() + + def _write_cache(self): + try: + with open(self.__cache_file, 'w') as f: + json.dump(self._cache, f, cls=CacheEncoder) + except Exception: + # Failing to write the cache isn't a big enough error to crash on + pass + + def invalidate_cache(self): + self.__init_cache(refresh=True) + + @abstractproperty + def api_groups(self): + pass + + @abstractmethod + def search(self, prefix=None, group=None, api_version=None, kind=None, **kwargs): + pass + + @abstractmethod + def discover(self): + pass + + @property + def version(self): + return self.__version + + def default_groups(self, request_resources=False): + groups = {} + groups['api'] = { '': { + 'v1': (ResourceGroup( True, resources=self.get_resources_for_api_version('api', '', 'v1', True) ) + if request_resources else ResourceGroup(True)) + }} + + groups[DISCOVERY_PREFIX] = {'': { + 'v1': ResourceGroup(True, resources = {"List": [ResourceList(self.client)]}) + }} + return groups + + def parse_api_groups(self, request_resources=False, update=False): + """ Discovers all API groups present in the cluster """ + if not self._cache.get('resources') or update: + self._cache['resources'] = self._cache.get('resources', {}) + groups_response = self.client.request('GET', '/{}'.format(DISCOVERY_PREFIX)).groups + + groups = self.default_groups(request_resources=request_resources) + + for group in groups_response: + new_group = {} + for version_raw in group['versions']: + version = version_raw['version'] + resource_group = self._cache.get('resources', {}).get(DISCOVERY_PREFIX, {}).get(group['name'], {}).get(version) + preferred = version_raw == group['preferredVersion'] + resources = resource_group.resources if resource_group else {} + if request_resources: + resources = self.get_resources_for_api_version(DISCOVERY_PREFIX, group['name'], version, preferred) + new_group[version] = ResourceGroup(preferred, resources=resources) + groups[DISCOVERY_PREFIX][group['name']] = new_group + self._cache['resources'].update(groups) + self._write_cache() + + return self._cache['resources'] + + def _load_server_info(self): + def just_json(_, serialized): + return serialized + + if not self._cache.get('version'): + try: + self._cache['version'] = { + 'kubernetes': self.client.request('get', '/version', serializer=just_json) + } + except (ValueError, MaxRetryError) as e: + if isinstance(e, MaxRetryError) and not isinstance(e.reason, ProtocolError): + raise + if not self.client.configuration.host.startswith("https://"): + raise ValueError("Host value %s should start with https:// when talking to HTTPS endpoint" % + self.client.configuration.host) + else: + raise + + self.__version = self._cache['version'] + + def get_resources_for_api_version(self, prefix, group, version, preferred): + """ returns a dictionary of resources associated with provided (prefix, group, version)""" + + resources = defaultdict(list) + subresources = {} + + path = '/'.join(filter(None, [prefix, group, version])) + resources_response = self.client.request('GET', path).resources or [] + + resources_raw = list(filter(lambda resource: '/' not in resource['name'], resources_response)) + subresources_raw = list(filter(lambda resource: '/' in resource['name'], resources_response)) + for subresource in subresources_raw: + resource, name = subresource['name'].split('/') + if not subresources.get(resource): + subresources[resource] = {} + subresources[resource][name] = subresource + + for resource in resources_raw: + # Prevent duplicate keys + for key in ('prefix', 'group', 'api_version', 'client', 'preferred'): + resource.pop(key, None) + + resourceobj = Resource( + prefix=prefix, + group=group, + api_version=version, + client=self.client, + preferred=preferred, + subresources=subresources.get(resource['name']), + **resource + ) + resources[resource['kind']].append(resourceobj) + + resource_list = ResourceList(self.client, group=group, api_version=version, base_kind=resource['kind']) + resources[resource_list.kind].append(resource_list) + return resources + + def get(self, **kwargs): + """ Same as search, but will throw an error if there are multiple or no + results. If there are multiple results and only one is an exact match + on api_version, that resource will be returned. + """ + results = self.search(**kwargs) + # If there are multiple matches, prefer exact matches on api_version + if len(results) > 1 and kwargs.get('api_version'): + results = [ + result for result in results if result.group_version == kwargs['api_version'] + ] + # If there are multiple matches, prefer non-List kinds + if len(results) > 1 and not all([isinstance(x, ResourceList) for x in results]): + results = [result for result in results if not isinstance(result, ResourceList)] + if len(results) == 1: + return results[0] + elif not results: + raise ResourceNotFoundError('No matches found for {}'.format(kwargs)) + else: + raise ResourceNotUniqueError('Multiple matches found for {}: {}'.format(kwargs, results)) + + +class LazyDiscoverer(Discoverer): + """ A convenient container for storing discovered API resources. Allows + easy searching and retrieval of specific resources. + + Resources for the cluster are loaded lazily. + """ + + def __init__(self, client, cache_file): + Discoverer.__init__(self, client, cache_file) + self.__update_cache = False + + def discover(self): + self.__resources = self.parse_api_groups(request_resources=False) + + def __maybe_write_cache(self): + if self.__update_cache: + self._write_cache() + self.__update_cache = False + + @property + def api_groups(self): + return self.parse_api_groups(request_resources=False, update=True)['apis'].keys() + + def search(self, **kwargs): + results = self.__search(self.__build_search(**kwargs), self.__resources, []) + if not results: + self.invalidate_cache() + results = self.__search(self.__build_search(**kwargs), self.__resources, []) + self.__maybe_write_cache() + return results + + def __search(self, parts, resources, reqParams): + part = parts[0] + if part != '*': + + resourcePart = resources.get(part) + if not resourcePart: + return [] + elif isinstance(resourcePart, ResourceGroup): + if len(reqParams) != 2: + raise ValueError("prefix and group params should be present, have %s" % reqParams) + # Check if we've requested resources for this group + if not resourcePart.resources: + prefix, group, version = reqParams[0], reqParams[1], part + try: + resourcePart.resources = self.get_resources_for_api_version(prefix, + group, part, resourcePart.preferred) + except NotFoundError: + raise ResourceNotFoundError + self._cache['resources'][prefix][group][version] = resourcePart + self.__update_cache=True + return self.__search(parts[1:], resourcePart.resources, reqParams) + elif isinstance(resourcePart, dict): + # In this case parts [0] will be a specified prefix, group, version + # as we recurse + return self.__search(parts[1:], resourcePart, reqParams + [part] ) + else: + if parts[1] != '*' and isinstance(parts[1], dict): + for _resource in resourcePart: + for term, value in parts[1].items(): + if getattr(_resource, term) == value: + return [_resource] + + return [] + else: + return resourcePart + else: + matches = [] + for key in resources.keys(): + matches.extend(self.__search([key] + parts[1:], resources, reqParams)) + return matches + + def __build_search(self, prefix=None, group=None, api_version=None, kind=None, **kwargs): + if not group and api_version and '/' in api_version: + group, api_version = api_version.split('/') + + items = [prefix, group, api_version, kind, kwargs] + return list(map(lambda x: x or '*', items)) + + def __iter__(self): + for prefix, groups in self.__resources.items(): + for group, versions in groups.items(): + for version, rg in versions.items(): + # Request resources for this groupVersion if we haven't yet + if not rg.resources: + rg.resources = self.get_resources_for_api_version( + prefix, group, version, rg.preferred) + self._cache['resources'][prefix][group][version] = rg + self.__update_cache = True + for _, resource in six.iteritems(rg.resources): + yield resource + self.__maybe_write_cache() + + +class EagerDiscoverer(Discoverer): + """ A convenient container for storing discovered API resources. Allows + easy searching and retrieval of specific resources. + + All resources are discovered for the cluster upon object instantiation. + """ + + def update(self, resources): + self.__resources = resources + + def __init__(self, client, cache_file): + Discoverer.__init__(self, client, cache_file) + + def discover(self): + self.__resources = self.parse_api_groups(request_resources=True) + + @property + def api_groups(self): + """ list available api groups """ + return self.parse_api_groups(request_resources=True, update=True)['apis'].keys() + + + def search(self, **kwargs): + """ Takes keyword arguments and returns matching resources. The search + will happen in the following order: + prefix: The api prefix for a resource, ie, /api, /oapi, /apis. Can usually be ignored + group: The api group of a resource. Will also be extracted from api_version if it is present there + api_version: The api version of a resource + kind: The kind of the resource + arbitrary arguments (see below), in random order + + The arbitrary arguments can be any valid attribute for an Resource object + """ + results = self.__search(self.__build_search(**kwargs), self.__resources) + if not results: + self.invalidate_cache() + results = self.__search(self.__build_search(**kwargs), self.__resources) + return results + + def __build_search(self, prefix=None, group=None, api_version=None, kind=None, **kwargs): + if not group and api_version and '/' in api_version: + group, api_version = api_version.split('/') + + items = [prefix, group, api_version, kind, kwargs] + return list(map(lambda x: x or '*', items)) + + def __search(self, parts, resources): + part = parts[0] + resourcePart = resources.get(part) + + if part != '*' and resourcePart: + if isinstance(resourcePart, ResourceGroup): + return self.__search(parts[1:], resourcePart.resources) + elif isinstance(resourcePart, dict): + return self.__search(parts[1:], resourcePart) + else: + if parts[1] != '*' and isinstance(parts[1], dict): + for _resource in resourcePart: + for term, value in parts[1].items(): + if getattr(_resource, term) == value: + return [_resource] + return [] + else: + return resourcePart + elif part == '*': + matches = [] + for key in resources.keys(): + matches.extend(self.__search([key] + parts[1:], resources)) + return matches + return [] + + def __iter__(self): + for _, groups in self.__resources.items(): + for _, versions in groups.items(): + for _, resources in versions.items(): + for _, resource in resources.items(): + yield resource + + +class ResourceGroup(object): + """Helper class for Discoverer container""" + def __init__(self, preferred, resources=None): + self.preferred = preferred + self.resources = resources or {} + + def to_dict(self): + return { + '_type': 'ResourceGroup', + 'preferred': self.preferred, + 'resources': self.resources, + } + + +class CacheEncoder(json.JSONEncoder): + + def default(self, o): + return o.to_dict() + + +class CacheDecoder(json.JSONDecoder): + def __init__(self, client, *args, **kwargs): + self.client = client + json.JSONDecoder.__init__(self, object_hook=self.object_hook, *args, **kwargs) + + def object_hook(self, obj): + if '_type' not in obj: + return obj + _type = obj.pop('_type') + if _type == 'Resource': + return Resource(client=self.client, **obj) + elif _type == 'ResourceList': + return ResourceList(self.client, **obj) + elif _type == 'ResourceGroup': + return ResourceGroup(obj['preferred'], resources=self.object_hook(obj['resources'])) + return obj diff --git a/dynamic/exceptions.py b/dynamic/exceptions.py new file mode 100644 index 00000000..63e79875 --- /dev/null +++ b/dynamic/exceptions.py @@ -0,0 +1,96 @@ +import json +import sys +import traceback + +from kubernetes.client.rest import ApiException + + +def api_exception(e): + """ + Returns the proper Exception class for the given kubernetes.client.rest.ApiException object + https://github.com/kubernetes/community/blob/master/contributors/devel/api-conventions.md#success-codes + """ + _, _, exc_traceback = sys.exc_info() + tb = '\n'.join(traceback.format_tb(exc_traceback)) + return { + 400: BadRequestError, + 401: UnauthorizedError, + 403: ForbiddenError, + 404: NotFoundError, + 405: MethodNotAllowedError, + 409: ConflictError, + 410: GoneError, + 422: UnprocessibleEntityError, + 429: TooManyRequestsError, + 500: InternalServerError, + 503: ServiceUnavailableError, + 504: ServerTimeoutError, + }.get(e.status, DynamicApiError)(e, tb) + + +class DynamicApiError(ApiException): + """ Generic API Error for the dynamic client """ + def __init__(self, e, tb=None): + self.status = e.status + self.reason = e.reason + self.body = e.body + self.headers = e.headers + self.original_traceback = tb + + def __str__(self): + error_message = [str(self.status), "Reason: {}".format(self.reason)] + if self.headers: + error_message.append("HTTP response headers: {}".format(self.headers)) + + if self.body: + error_message.append("HTTP response body: {}".format(self.body)) + + if self.original_traceback: + error_message.append("Original traceback: \n{}".format(self.original_traceback)) + + return '\n'.join(error_message) + + def summary(self): + if self.body: + if self.headers and self.headers.get('Content-Type') == 'application/json': + message = json.loads(self.body).get('message') + if message: + return message + + return self.body + else: + return "{} Reason: {}".format(self.status, self.reason) + +class ResourceNotFoundError(Exception): + """ Resource was not found in available APIs """ +class ResourceNotUniqueError(Exception): + """ Parameters given matched multiple API resources """ + +class KubernetesValidateMissing(Exception): + """ kubernetes-validate is not installed """ + +# HTTP Errors +class BadRequestError(DynamicApiError): + """ 400: StatusBadRequest """ +class UnauthorizedError(DynamicApiError): + """ 401: StatusUnauthorized """ +class ForbiddenError(DynamicApiError): + """ 403: StatusForbidden """ +class NotFoundError(DynamicApiError): + """ 404: StatusNotFound """ +class MethodNotAllowedError(DynamicApiError): + """ 405: StatusMethodNotAllowed """ +class ConflictError(DynamicApiError): + """ 409: StatusConflict """ +class GoneError(DynamicApiError): + """ 410: StatusGone """ +class UnprocessibleEntityError(DynamicApiError): + """ 422: StatusUnprocessibleEntity """ +class TooManyRequestsError(DynamicApiError): + """ 429: StatusTooManyRequests """ +class InternalServerError(DynamicApiError): + """ 500: StatusInternalServer """ +class ServiceUnavailableError(DynamicApiError): + """ 503: StatusServiceUnavailable """ +class ServerTimeoutError(DynamicApiError): + """ 504: StatusServerTimeout """ diff --git a/dynamic/resource.py b/dynamic/resource.py new file mode 100644 index 00000000..17c14c4d --- /dev/null +++ b/dynamic/resource.py @@ -0,0 +1,371 @@ +import copy +import yaml +from functools import partial + +from pprint import pformat + + +class Resource(object): + """ Represents an API resource type, containing the information required to build urls for requests """ + + def __init__(self, prefix=None, group=None, api_version=None, kind=None, + namespaced=False, verbs=None, name=None, preferred=False, client=None, + singularName=None, shortNames=None, categories=None, subresources=None, **kwargs): + + if None in (api_version, kind, prefix): + raise ValueError("At least prefix, kind, and api_version must be provided") + + self.prefix = prefix + self.group = group + self.api_version = api_version + self.kind = kind + self.namespaced = namespaced + self.verbs = verbs + self.name = name + self.preferred = preferred + self.client = client + self.singular_name = singularName or (name[:-1] if name else "") + self.short_names = shortNames + self.categories = categories + self.subresources = { + k: Subresource(self, **v) for k, v in (subresources or {}).items() + } + + self.extra_args = kwargs + + def to_dict(self): + return { + '_type': 'Resource', + 'prefix': self.prefix, + 'group': self.group, + 'api_version': self.api_version, + 'kind': self.kind, + 'namespaced': self.namespaced, + 'verbs': self.verbs, + 'name': self.name, + 'preferred': self.preferred, + 'singular_name': self.singular_name, + 'short_names': self.short_names, + 'categories': self.categories, + 'subresources': {k: sr.to_dict() for k, sr in self.subresources.items()}, + 'extra_args': self.extra_args, + } + + @property + def group_version(self): + if self.group: + return '{}/{}'.format(self.group, self.api_version) + return self.api_version + + def __repr__(self): + return '<{}({}/{})>'.format(self.__class__.__name__, self.group_version, self.name) + + @property + def urls(self): + full_prefix = '{}/{}'.format(self.prefix, self.group_version) + resource_name = self.name.lower() + return { + 'base': '/{}/{}'.format(full_prefix, resource_name), + 'namespaced_base': '/{}/namespaces/{{namespace}}/{}'.format(full_prefix, resource_name), + 'full': '/{}/{}/{{name}}'.format(full_prefix, resource_name), + 'namespaced_full': '/{}/namespaces/{{namespace}}/{}/{{name}}'.format(full_prefix, resource_name) + } + + def path(self, name=None, namespace=None): + url_type = [] + path_params = {} + if self.namespaced and namespace: + url_type.append('namespaced') + path_params['namespace'] = namespace + if name: + url_type.append('full') + path_params['name'] = name + else: + url_type.append('base') + return self.urls['_'.join(url_type)].format(**path_params) + + def __getattr__(self, name): + if name in self.subresources: + return self.subresources[name] + return partial(getattr(self.client, name), self) + + +class ResourceList(Resource): + """ Represents a list of API objects """ + + def __init__(self, client, group='', api_version='v1', base_kind='', kind=None): + self.client = client + self.group = group + self.api_version = api_version + self.kind = kind or '{}List'.format(base_kind) + self.base_kind = base_kind + self.__base_resource = None + + def base_resource(self): + if self.__base_resource: + return self.__base_resource + elif self.base_kind: + self.__base_resource = self.client.resources.get(group=self.group, api_version=self.api_version, kind=self.base_kind) + return self.__base_resource + return None + + def _items_to_resources(self, body): + """ Takes a List body and return a dictionary with the following structure: + { + 'api_version': str, + 'kind': str, + 'items': [{ + 'resource': Resource, + 'name': str, + 'namespace': str, + }] + } + """ + if body is None: + raise ValueError("You must provide a body when calling methods on a ResourceList") + + api_version = body['apiVersion'] + kind = body['kind'] + items = body.get('items') + if not items: + raise ValueError('The `items` field in the body must be populated when calling methods on a ResourceList') + + if self.kind != kind: + raise ValueError('Methods on a {} must be called with a body containing the same kind. Receieved {} instead'.format(self.kind, kind)) + + return { + 'api_version': api_version, + 'kind': kind, + 'items': [self._item_to_resource(item) for item in items] + } + + def _item_to_resource(self, item): + metadata = item.get('metadata', {}) + resource = self.base_resource() + if not resource: + api_version = item.get('apiVersion', self.api_version) + kind = item.get('kind', self.base_kind) + resource = self.client.resources.get(api_version=api_version, kind=kind) + return { + 'resource': resource, + 'definition': item, + 'name': metadata.get('name'), + 'namespace': metadata.get('namespace') + } + + def get(self, body, name=None, namespace=None, **kwargs): + if name: + raise ValueError('Operations on ResourceList objects do not support the `name` argument') + resource_list = self._items_to_resources(body) + response = copy.deepcopy(body) + + response['items'] = [ + item['resource'].get(name=item['name'], namespace=item['namespace'] or namespace, **kwargs).to_dict() + for item in resource_list['items'] + ] + return ResourceInstance(self, response) + + def delete(self, body, name=None, namespace=None, **kwargs): + if name: + raise ValueError('Operations on ResourceList objects do not support the `name` argument') + resource_list = self._items_to_resources(body) + response = copy.deepcopy(body) + + response['items'] = [ + item['resource'].delete(name=item['name'], namespace=item['namespace'] or namespace, **kwargs).to_dict() + for item in resource_list['items'] + ] + return ResourceInstance(self, response) + + def verb_mapper(self, verb, body, **kwargs): + resource_list = self._items_to_resources(body) + response = copy.deepcopy(body) + response['items'] = [ + getattr(item['resource'], verb)(body=item['definition'], **kwargs).to_dict() + for item in resource_list['items'] + ] + return ResourceInstance(self, response) + + def create(self, *args, **kwargs): + return self.verb_mapper('create', *args, **kwargs) + + def replace(self, *args, **kwargs): + return self.verb_mapper('replace', *args, **kwargs) + + def patch(self, *args, **kwargs): + return self.verb_mapper('patch', *args, **kwargs) + + def to_dict(self): + return { + '_type': 'ResourceList', + 'group': self.group, + 'api_version': self.api_version, + 'kind': self.kind, + 'base_kind': self.base_kind + } + + def __getattr__(self, name): + if self.base_resource(): + return getattr(self.base_resource(), name) + return None + + +class Subresource(Resource): + """ Represents a subresource of an API resource. This generally includes operations + like scale, as well as status objects for an instantiated resource + """ + + def __init__(self, parent, **kwargs): + self.parent = parent + self.prefix = parent.prefix + self.group = parent.group + self.api_version = parent.api_version + self.kind = kwargs.pop('kind') + self.name = kwargs.pop('name') + self.subresource = self.name.split('/')[1] + self.namespaced = kwargs.pop('namespaced', False) + self.verbs = kwargs.pop('verbs', None) + self.extra_args = kwargs + + #TODO(fabianvf): Determine proper way to handle differences between resources + subresources + def create(self, body=None, name=None, namespace=None, **kwargs): + name = name or body.get('metadata', {}).get('name') + body = self.parent.client.serialize_body(body) + if self.parent.namespaced: + namespace = self.parent.client.ensure_namespace(self.parent, namespace, body) + path = self.path(name=name, namespace=namespace) + return self.parent.client.request('post', path, body=body, **kwargs) + + @property + def urls(self): + full_prefix = '{}/{}'.format(self.prefix, self.group_version) + return { + 'full': '/{}/{}/{{name}}/{}'.format(full_prefix, self.parent.name, self.subresource), + 'namespaced_full': '/{}/namespaces/{{namespace}}/{}/{{name}}/{}'.format(full_prefix, self.parent.name, self.subresource) + } + + def __getattr__(self, name): + return partial(getattr(self.parent.client, name), self) + + def to_dict(self): + return { + 'kind': self.kind, + 'name': self.name, + 'subresource': self.subresource, + 'namespaced': self.namespaced, + 'verbs': self.verbs, + 'extra_args': self.extra_args, + } + + +class ResourceInstance(object): + """ A parsed instance of an API resource. It exists solely to + ease interaction with API objects by allowing attributes to + be accessed with '.' notation. + """ + + def __init__(self, client, instance): + self.client = client + # If we have a list of resources, then set the apiVersion and kind of + # each resource in 'items' + kind = instance['kind'] + if kind.endswith('List') and 'items' in instance: + kind = instance['kind'][:-4] + for item in instance['items']: + if 'apiVersion' not in item: + item['apiVersion'] = instance['apiVersion'] + if 'kind' not in item: + item['kind'] = kind + + self.attributes = self.__deserialize(instance) + self.__initialised = True + + def __deserialize(self, field): + if isinstance(field, dict): + return ResourceField(**{ + k: self.__deserialize(v) for k, v in field.items() + }) + elif isinstance(field, (list, tuple)): + return [self.__deserialize(item) for item in field] + else: + return field + + def __serialize(self, field): + if isinstance(field, ResourceField): + return { + k: self.__serialize(v) for k, v in field.__dict__.items() + } + elif isinstance(field, (list, tuple)): + return [self.__serialize(item) for item in field] + elif isinstance(field, ResourceInstance): + return field.to_dict() + else: + return field + + def to_dict(self): + return self.__serialize(self.attributes) + + def to_str(self): + return repr(self) + + def __repr__(self): + return "ResourceInstance[{}]:\n {}".format( + self.attributes.kind, + ' '.join(yaml.safe_dump(self.to_dict()).splitlines(True)) + ) + + def __getattr__(self, name): + if not '_ResourceInstance__initialised' in self.__dict__: + return super(ResourceInstance, self).__getattr__(name) + return getattr(self.attributes, name) + + def __setattr__(self, name, value): + if not '_ResourceInstance__initialised' in self.__dict__: + return super(ResourceInstance, self).__setattr__(name, value) + elif name in self.__dict__: + return super(ResourceInstance, self).__setattr__(name, value) + else: + self.attributes[name] = value + + def __getitem__(self, name): + return self.attributes[name] + + def __setitem__(self, name, value): + self.attributes[name] = value + + def __dir__(self): + return dir(type(self)) + list(self.attributes.__dict__.keys()) + + +class ResourceField(object): + """ A parsed instance of an API resource attribute. It exists + solely to ease interaction with API objects by allowing + attributes to be accessed with '.' notation + """ + + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + def __repr__(self): + return pformat(self.__dict__) + + def __eq__(self, other): + return self.__dict__ == other.__dict__ + + def __getitem__(self, name): + return self.__dict__.get(name) + + # Here resource.items will return items if available or resource.__dict__.items function if not + # resource.get will call resource.__dict__.get after attempting resource.__dict__.get('get') + def __getattr__(self, name): + return self.__dict__.get(name, getattr(self.__dict__, name, None)) + + def __setattr__(self, name, value): + self.__dict__[name] = value + + def __dir__(self): + return dir(type(self)) + list(self.__dict__.keys()) + + def __iter__(self): + for k, v in self.__dict__.items(): + yield (k, v) From 5b21d65aaea74325210c58137db109e9cc14c4bd Mon Sep 17 00:00:00 2001 From: Fabian von Feilitzsch Date: Fri, 28 Jun 2019 16:27:35 -0400 Subject: [PATCH 028/141] Add tests for dynamic client --- dynamic/test_client.py | 362 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 dynamic/test_client.py diff --git a/dynamic/test_client.py b/dynamic/test_client.py new file mode 100644 index 00000000..ba92332d --- /dev/null +++ b/dynamic/test_client.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import time +import unittest +import uuid + +from kubernetes.e2e_test import base +from kubernetes.client import api_client + +from . import DynamicClient +from .exceptions import ResourceNotFoundError + + +def short_uuid(): + id = str(uuid.uuid4()) + return id[-12:] + + +class TestDynamicClient(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.config = base.get_e2e_configuration() + + def test_cluster_custom_resources(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + + with self.assertRaises(ResourceNotFoundError): + changeme_api = client.resources.get( + api_version='apps.example.com/v1', kind='ClusterChangeMe') + + crd_api = client.resources.get(kind='CustomResourceDefinition') + name = 'clusterchangemes.apps.example.com' + crd_manifest = { + 'apiVersion': 'apiextensions.k8s.io/v1beta1', + 'kind': 'CustomResourceDefinition', + 'metadata': { + 'name': name, + }, + 'spec': { + 'group': 'apps.example.com', + 'names': { + 'kind': 'ClusterChangeMe', + 'listKind': 'ClusterChangeMeList', + 'plural': 'clusterchangemes', + 'singular': 'clusterchangeme', + }, + 'scope': 'Cluster', + 'version': 'v1', + 'subresources': { + 'status': {} + } + } + } + resp = crd_api.create(crd_manifest) + + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + resp = crd_api.get( + name=name, + ) + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + try: + changeme_api = client.resources.get( + api_version='apps.example.com/v1', kind='ClusterChangeMe') + except ResourceNotFoundError: + # Need to wait a sec for the discovery layer to get updated + time.sleep(2) + changeme_api = client.resources.get( + api_version='apps.example.com/v1', kind='ClusterChangeMe') + resp = changeme_api.get() + self.assertEqual(resp.items, []) + changeme_name = 'custom-resource' + short_uuid() + changeme_manifest = { + 'apiVersion': 'apps.example.com/v1', + 'kind': 'ClusterChangeMe', + 'metadata': { + 'name': changeme_name, + }, + 'spec': {} + } + + resp = changeme_api.create(body=changeme_manifest) + self.assertEqual(resp.metadata.name, changeme_name) + + resp = changeme_api.get(name=changeme_name) + self.assertEqual(resp.metadata.name, changeme_name) + + changeme_manifest['spec']['size'] = 3 + resp = changeme_api.patch( + body=changeme_manifest, + content_type='application/merge-patch+json' + ) + self.assertEqual(resp.spec.size, 3) + + resp = changeme_api.get(name=changeme_name) + self.assertEqual(resp.spec.size, 3) + + resp = changeme_api.get() + self.assertEqual(len(resp.items), 1) + + resp = changeme_api.delete( + name=changeme_name, + ) + + resp = changeme_api.get() + self.assertEqual(len(resp.items), 0) + + resp = crd_api.delete( + name=name, + ) + + time.sleep(2) + client.resources.invalidate_cache() + with self.assertRaises(ResourceNotFoundError): + changeme_api = client.resources.get( + api_version='apps.example.com/v1', kind='ClusterChangeMe') + + def test_namespaced_custom_resources(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + + with self.assertRaises(ResourceNotFoundError): + changeme_api = client.resources.get( + api_version='apps.example.com/v1', kind='ChangeMe') + + crd_api = client.resources.get(kind='CustomResourceDefinition') + name = 'changemes.apps.example.com' + crd_manifest = { + 'apiVersion': 'apiextensions.k8s.io/v1beta1', + 'kind': 'CustomResourceDefinition', + 'metadata': { + 'name': name, + }, + 'spec': { + 'group': 'apps.example.com', + 'names': { + 'kind': 'ChangeMe', + 'listKind': 'ChangeMeList', + 'plural': 'changemes', + 'singular': 'changeme', + }, + 'scope': 'Namespaced', + 'version': 'v1', + 'subresources': { + 'status': {} + } + } + } + resp = crd_api.create(crd_manifest) + + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + resp = crd_api.get( + name=name, + ) + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + try: + changeme_api = client.resources.get( + api_version='apps.example.com/v1', kind='ChangeMe') + except ResourceNotFoundError: + # Need to wait a sec for the discovery layer to get updated + time.sleep(2) + changeme_api = client.resources.get( + api_version='apps.example.com/v1', kind='ChangeMe') + resp = changeme_api.get() + self.assertEqual(resp.items, []) + changeme_name = 'custom-resource' + short_uuid() + changeme_manifest = { + 'apiVersion': 'apps.example.com/v1', + 'kind': 'ChangeMe', + 'metadata': { + 'name': changeme_name, + }, + 'spec': {} + } + + resp = changeme_api.create(body=changeme_manifest, namespace='default') + self.assertEqual(resp.metadata.name, changeme_name) + + resp = changeme_api.get(name=changeme_name, namespace='default') + self.assertEqual(resp.metadata.name, changeme_name) + + changeme_manifest['spec']['size'] = 3 + resp = changeme_api.patch( + body=changeme_manifest, + namespace='default', + content_type='application/merge-patch+json' + ) + self.assertEqual(resp.spec.size, 3) + + resp = changeme_api.get(name=changeme_name, namespace='default') + self.assertEqual(resp.spec.size, 3) + + resp = changeme_api.get(namespace='default') + self.assertEqual(len(resp.items), 1) + + resp = changeme_api.get() + self.assertEqual(len(resp.items), 1) + + resp = changeme_api.delete( + name=changeme_name, + namespace='default' + ) + + resp = changeme_api.get(namespace='default') + self.assertEqual(len(resp.items), 0) + + resp = changeme_api.get() + self.assertEqual(len(resp.items), 0) + + resp = crd_api.delete( + name=name, + ) + + time.sleep(2) + client.resources.invalidate_cache() + with self.assertRaises(ResourceNotFoundError): + changeme_api = client.resources.get( + api_version='apps.example.com/v1', kind='ChangeMe') + + def test_service_apis(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + api = client.resources.get(api_version='v1', kind='Service') + + name = 'frontend-' + short_uuid() + service_manifest = {'apiVersion': 'v1', + 'kind': 'Service', + 'metadata': {'labels': {'name': name}, + 'name': name, + 'resourceversion': 'v1'}, + 'spec': {'ports': [{'name': 'port', + 'port': 80, + 'protocol': 'TCP', + 'targetPort': 80}], + 'selector': {'name': name}}} + + resp = api.create( + body=service_manifest, + namespace='default' + ) + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + resp = api.get( + name=name, + namespace='default' + ) + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + service_manifest['spec']['ports'] = [{'name': 'new', + 'port': 8080, + 'protocol': 'TCP', + 'targetPort': 8080}] + resp = api.patch( + body=service_manifest, + name=name, + namespace='default' + ) + self.assertEqual(2, len(resp.spec.ports)) + self.assertTrue(resp.status) + + resp = api.delete( + name=name, body={}, + namespace='default' + ) + + def test_replication_controller_apis(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + api = client.resources.get( + api_version='v1', kind='ReplicationController') + + name = 'frontend-' + short_uuid() + rc_manifest = { + 'apiVersion': 'v1', + 'kind': 'ReplicationController', + 'metadata': {'labels': {'name': name}, + 'name': name}, + 'spec': {'replicas': 2, + 'selector': {'name': name}, + 'template': {'metadata': { + 'labels': {'name': name}}, + 'spec': {'containers': [{ + 'image': 'nginx', + 'name': 'nginx', + 'ports': [{'containerPort': 80, + 'protocol': 'TCP'}]}]}}}} + + resp = api.create( + body=rc_manifest, namespace='default') + self.assertEqual(name, resp.metadata.name) + self.assertEqual(2, resp.spec.replicas) + + resp = api.get( + name=name, namespace='default') + self.assertEqual(name, resp.metadata.name) + self.assertEqual(2, resp.spec.replicas) + + resp = api.delete( + name=name, body={}, namespace='default') + + def test_configmap_apis(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + api = client.resources.get(api_version='v1', kind='ConfigMap') + + name = 'test-configmap-' + short_uuid() + test_configmap = { + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": name, + }, + "data": { + "config.json": "{\"command\":\"/usr/bin/mysqld_safe\"}", + "frontend.cnf": "[mysqld]\nbind-address = 10.0.0.3\n" + } + } + + resp = api.create( + body=test_configmap, namespace='default' + ) + self.assertEqual(name, resp.metadata.name) + + resp = api.get( + name=name, namespace='default') + self.assertEqual(name, resp.metadata.name) + + test_configmap['data']['config.json'] = "{}" + resp = api.patch( + name=name, namespace='default', body=test_configmap) + + resp = api.delete( + name=name, body={}, namespace='default') + + resp = api.get(namespace='default', pretty=True) + self.assertEqual([], resp.items) + + def test_node_apis(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + api = client.resources.get(api_version='v1', kind='Node') + + for item in api.get().items: + node = api.get(name=item.metadata.name) + self.assertTrue(len(dict(node.metadata.labels)) > 0) From 53c4cb23a30074a6cb2f8b34486f7a68015b855c Mon Sep 17 00:00:00 2001 From: Fabian von Feilitzsch Date: Fri, 28 Jun 2019 16:39:15 -0400 Subject: [PATCH 029/141] Add boilerplate --- dynamic/__init__.py | 16 ++++++++++++++++ dynamic/client.py | 16 ++++++++++++++++ dynamic/discovery.py | 16 ++++++++++++++++ dynamic/exceptions.py | 16 ++++++++++++++++ dynamic/resource.py | 16 ++++++++++++++++ dynamic/test_client.py | 20 +++++++++++--------- 6 files changed, 91 insertions(+), 9 deletions(-) diff --git a/dynamic/__init__.py b/dynamic/__init__.py index b5df5482..91ba0501 100644 --- a/dynamic/__init__.py +++ b/dynamic/__init__.py @@ -1 +1,17 @@ +#!/usr/bin/env python + +# Copyright 2019 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from .client import * # NOQA diff --git a/dynamic/client.py b/dynamic/client.py index ba05d36b..02bb984b 100644 --- a/dynamic/client.py +++ b/dynamic/client.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2019 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import six import json diff --git a/dynamic/discovery.py b/dynamic/discovery.py index d0011322..a646a96a 100644 --- a/dynamic/discovery.py +++ b/dynamic/discovery.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2019 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import six import json diff --git a/dynamic/exceptions.py b/dynamic/exceptions.py index 63e79875..d940d429 100644 --- a/dynamic/exceptions.py +++ b/dynamic/exceptions.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2019 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import json import sys import traceback diff --git a/dynamic/resource.py b/dynamic/resource.py index 17c14c4d..3e2897cd 100644 --- a/dynamic/resource.py +++ b/dynamic/resource.py @@ -1,3 +1,19 @@ +#!/usr/bin/env python + +# Copyright 2019 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import copy import yaml from functools import partial diff --git a/dynamic/test_client.py b/dynamic/test_client.py index ba92332d..d6d65c6d 100644 --- a/dynamic/test_client.py +++ b/dynamic/test_client.py @@ -1,16 +1,18 @@ -# -*- coding: utf-8 -*- +#!/usr/bin/env python -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at +# Copyright 2019 The Kubernetes Authors. # -# http://www.apache.org/licenses/LICENSE-2.0 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. import time import unittest From 382707436f863443219a4adf5461f393f93e7503 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Wed, 14 Aug 2019 11:00:17 +0200 Subject: [PATCH 030/141] Refs. #151 -- detect binary payloads and send the correct opcode On Python 2, strings are bytestrings either way. On Python 3, the result of `chr(channel)` is `str`, while the data itself is `bytes`. The channel prefix needs to be turned into a binary type, and the websocket frame needs the correct opcode (binary vs. text). See #151 for the bug report and related issues. --- stream/ws_client.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index a1a96f2d..590bd8d7 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -116,7 +116,16 @@ def readline_channel(self, channel, timeout=None): def write_channel(self, channel, data): """Write data to a channel.""" - self.sock.send(chr(channel) + data) + # check if we're writing binary data or not + binary = six.PY3 and type(data) == six.binary_type + opcode = ABNF.OPCODE_BINARY if binary else ABNF.OPCODE_TEXT + + channel_prefix = chr(channel) + if binary: + channel_prefix = six.binary_type(channel_prefix, "ascii") + + payload = channel_prefix + data + self.sock.send(payload, opcode=opcode) def peek_stdout(self, timeout=0): """Same as peek_channel with channel=1.""" From c941d74b3745550fc886ebf20e40725aa4722f5c Mon Sep 17 00:00:00 2001 From: Oz N Tiram Date: Tue, 20 Aug 2019 22:25:57 +0200 Subject: [PATCH 031/141] Remove all shebangs from Python modules and checker As discussed, Python modules which aren't intended to be invoked as scripts should not include a shebang line. Update CONTRIBUTING.md and the checker script. This script now includes a list SKIP_FILES for files that should not be checked for boilerplate template. The tests will now fail if a Python module has a shebang line. Scripts which should have a shebang line and exists in the directory `hack` can be ignored by adding them to the SKIP_FILES list. --- CONTRIBUTING.md | 18 +++++++++++++++++- config/__init__.py | 2 -- config/config_exception.py | 2 -- config/dateutil.py | 2 -- config/dateutil_test.py | 2 -- config/exec_provider.py | 2 -- config/exec_provider_test.py | 2 -- config/incluster_config.py | 2 -- config/incluster_config_test.py | 2 -- config/kube_config.py | 2 -- config/kube_config_test.py | 2 -- dynamic/__init__.py | 2 -- dynamic/client.py | 2 -- dynamic/discovery.py | 2 -- dynamic/exceptions.py | 2 -- dynamic/resource.py | 2 -- dynamic/test_client.py | 2 -- hack/boilerplate/boilerplate.py | 12 ++++++++---- hack/boilerplate/boilerplate.py.txt | 2 -- stream/__init__.py | 2 -- stream/stream.py | 2 -- stream/ws_client.py | 2 -- stream/ws_client_test.py | 2 -- watch/__init__.py | 2 -- watch/watch.py | 2 -- watch/watch_test.py | 2 -- 26 files changed, 25 insertions(+), 53 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 252a5554..73862f46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Thanks for taking the time to join our community and start contributing! -Any changes to utilites in this repo should be send as a PR to this repo. +Any changes to utilities in this repo should be send as a PR to this repo. After the PR is merged, developers should create another PR in the main repo to update the submodule. See [this document](https://github.com/kubernetes-client/python/blob/master/devel/submodules.md) for more guidelines. @@ -11,3 +11,19 @@ provides detailed instructions on how to get your ideas and bug fixes seen and a Please remember to sign the [CNCF CLA](https://github.com/kubernetes/community/blob/master/CLA.md) and read and observe the [Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). + +## Adding new Python modules or Python scripts +If you add a new Python module please make sure it includes the correct header +as found in: +``` +hack/boilerplate/boilerplate.py.txt +``` + +This module should not include a shebang line. + +If you add a new Python helper script intended for developers usage, it should +go into the directory `hack` and include a shebang line `#!/usr/bin/env python` +at the top in addition to rest of the boilerplate text as in all other modules. + +In addition this script's name should be added to the list +`SKIP_FILES` at the top of hack/boilerplate/boilerplate.py. diff --git a/config/__init__.py b/config/__init__.py index 02a7532d..3476ff71 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/config_exception.py b/config/config_exception.py index 9bf049c6..23fab022 100644 --- a/config/config_exception.py +++ b/config/config_exception.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/dateutil.py b/config/dateutil.py index db880efa..972e003e 100644 --- a/config/dateutil.py +++ b/config/dateutil.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2017 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/dateutil_test.py b/config/dateutil_test.py index f5587d6e..933360d9 100644 --- a/config/dateutil_test.py +++ b/config/dateutil_test.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/exec_provider.py b/config/exec_provider.py index a0348f1e..4008f2e8 100644 --- a/config/exec_provider.py +++ b/config/exec_provider.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/exec_provider_test.py b/config/exec_provider_test.py index 8b6517b0..44579beb 100644 --- a/config/exec_provider_test.py +++ b/config/exec_provider_test.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/incluster_config.py b/config/incluster_config.py index c9bdc907..6f28a4ae 100644 --- a/config/incluster_config.py +++ b/config/incluster_config.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/incluster_config_test.py b/config/incluster_config_test.py index 3cb0abfc..622b31b3 100644 --- a/config/incluster_config_test.py +++ b/config/incluster_config_test.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/kube_config.py b/config/kube_config.py index 386b82c1..3384d272 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 04f6b11e..b29525e8 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/dynamic/__init__.py b/dynamic/__init__.py index 91ba0501..a1d3d8f8 100644 --- a/dynamic/__init__.py +++ b/dynamic/__init__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2019 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/dynamic/client.py b/dynamic/client.py index 02bb984b..7b82b3d6 100644 --- a/dynamic/client.py +++ b/dynamic/client.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2019 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/dynamic/discovery.py b/dynamic/discovery.py index a646a96a..9468a274 100644 --- a/dynamic/discovery.py +++ b/dynamic/discovery.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2019 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/dynamic/exceptions.py b/dynamic/exceptions.py index d940d429..c8b908e7 100644 --- a/dynamic/exceptions.py +++ b/dynamic/exceptions.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2019 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/dynamic/resource.py b/dynamic/resource.py index 3e2897cd..c83ae9fd 100644 --- a/dynamic/resource.py +++ b/dynamic/resource.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2019 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/dynamic/test_client.py b/dynamic/test_client.py index d6d65c6d..dc52a30d 100644 --- a/dynamic/test_client.py +++ b/dynamic/test_client.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2019 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/hack/boilerplate/boilerplate.py b/hack/boilerplate/boilerplate.py index 61d4cb94..eec04b45 100755 --- a/hack/boilerplate/boilerplate.py +++ b/hack/boilerplate/boilerplate.py @@ -24,6 +24,10 @@ import re import sys +# list all the files contain a shebang line and should be ignored by this +# script +SKIP_FILES = ['hack/boilerplate/boilerplate.py'] + parser = argparse.ArgumentParser() parser.add_argument( "filenames", @@ -132,10 +136,6 @@ def file_extension(filename): return os.path.splitext(filename)[1].split(".")[-1].lower() -# list all the files contain 'DO NOT EDIT', but are not generated -skipped_ungenerated_files = ['hack/boilerplate/boilerplate.py'] - - def normalize_files(files): newfiles = [] for pathname in files: @@ -143,10 +143,12 @@ def normalize_files(files): for i, pathname in enumerate(newfiles): if not os.path.isabs(pathname): newfiles[i] = os.path.join(args.rootdir, pathname) + return newfiles def get_files(extensions): + files = [] if len(args.filenames) > 0: files = args.filenames @@ -163,6 +165,8 @@ def get_files(extensions): extension = file_extension(pathname) if extension in extensions or basename in extensions: outfiles.append(pathname) + + outfiles = list(set(outfiles) - set(normalize_files(SKIP_FILES))) return outfiles diff --git a/hack/boilerplate/boilerplate.py.txt b/hack/boilerplate/boilerplate.py.txt index d781daf9..34cb349c 100644 --- a/hack/boilerplate/boilerplate.py.txt +++ b/hack/boilerplate/boilerplate.py.txt @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright YEAR The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/stream/__init__.py b/stream/__init__.py index e9b7d24f..e72d0583 100644 --- a/stream/__init__.py +++ b/stream/__init__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2017 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/stream/stream.py b/stream/stream.py index 3eab0b9a..a9d0b402 100644 --- a/stream/stream.py +++ b/stream/stream.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/stream/ws_client.py b/stream/ws_client.py index a1a96f2d..65f0df17 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/stream/ws_client_test.py b/stream/ws_client_test.py index 756d9597..a8f4049d 100644 --- a/stream/ws_client_test.py +++ b/stream/ws_client_test.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2018 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/watch/__init__.py b/watch/__init__.py index 46a31ced..ca9ac069 100644 --- a/watch/__init__.py +++ b/watch/__init__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/watch/watch.py b/watch/watch.py index 5966eace..77b97947 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/watch/watch_test.py b/watch/watch_test.py index ebc400af..6fec23ec 100644 --- a/watch/watch_test.py +++ b/watch/watch_test.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # Copyright 2016 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); From 34f3d05c845653e0dfe9e67cfc5b0690f357bdbc Mon Sep 17 00:00:00 2001 From: AyliD Date: Thu, 29 Aug 2019 07:46:40 +0300 Subject: [PATCH 032/141] Update ws_client.py to support proxy --- stream/ws_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index a1a96f2d..0ffc1b44 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -74,7 +74,11 @@ def __init__(self, configuration, url, headers): ssl_opts['keyfile'] = configuration.key_file self.sock = WebSocket(sslopt=ssl_opts, skip_utf8_validation=False) - self.sock.connect(url, header=header) + if configuration.proxy: + proxy_url = urlparse(configuration.proxy) + self.sock.connect(url, header=header, http_proxy_host=proxy_url.hostname, http_proxy_port=proxy_url.port) + else: + self.sock.connect(url, header=header) self._connected = True def peek_channel(self, channel, timeout=0): From 69570ac7088e78edab2d41809a86be2cd9e6e2fd Mon Sep 17 00:00:00 2001 From: Oz Tiram Date: Tue, 17 Sep 2019 16:45:12 +0200 Subject: [PATCH 033/141] Add property returncode to WSClient This will be familiar for Python users as subprocess.Popen has the same attribute. Also, the behavior is such the returncode returns a None value if the process was not run yet. Other than that, when the process exists with a numerical code this will be the value of the return code. If the command executed successfully the return value will be 0. --- stream/ws_client.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 65f0df17..935c9885 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -14,15 +14,19 @@ from kubernetes.client.rest import ApiException -import select import certifi -import time import collections -from websocket import WebSocket, ABNF, enableTrace -import six +import select import ssl +import time + +import six +import yaml + from six.moves.urllib.parse import urlencode, quote_plus, urlparse, urlunparse +from websocket import WebSocket, ABNF, enableTrace + STDIN_CHANNEL = 0 STDOUT_CHANNEL = 1 STDERR_CHANNEL = 2 @@ -203,6 +207,21 @@ def run_forever(self, timeout=None): else: while self.is_open(): self.update(timeout=None) + @property + def returncode(self): + """ + The return code, A None value indicates that the process hasn't + terminated yet. + """ + if self.is_open(): + return None + else: + err = self.read_channel(ERROR_CHANNEL) + err = yaml.safe_load(err) + if err['status'] == "Success": + return 0 + return int(err['details']['causes'][0]['message']) + def close(self, **kwargs): """ From a29bf292cce41060561c9a11a2859f80862e9023 Mon Sep 17 00:00:00 2001 From: Quentin Lemaire Date: Fri, 6 Sep 2019 09:57:16 +0200 Subject: [PATCH 034/141] Add checks for None config file --- config/kube_config.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 3384d272..9f3df7ea 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -140,7 +140,11 @@ def __init__(self, config_dict, active_context=None, config_base_path="", config_persister=None): - if isinstance(config_dict, ConfigNode): + if config_dict is None: + raise ConfigException( + 'Invalid kube-config. ' + 'Expected config_dict to not be None.') + elif isinstance(config_dict, ConfigNode): self._config = config_dict else: self._config = ConfigNode('kube-config', config_dict) @@ -613,6 +617,11 @@ def _get_kube_config_loader_for_yaml_file( if persist_config and 'config_persister' not in kwargs: kwargs['config_persister'] = kcfg.save_changes() + if kcfg.config is None: + raise ConfigException( + 'Invalid kube-config file. ' + 'No configuration found.') + return KubeConfigLoader( config_dict=kcfg.config, config_base_path=None, From 6dec0447581e2a19e7fbe7664c2148bb54d4dc7a Mon Sep 17 00:00:00 2001 From: Evgeniy Ganchurin Date: Thu, 19 Sep 2019 02:46:43 +0300 Subject: [PATCH 035/141] Issue-954 - Support false values in configuration file --- config/kube_config.py | 2 +- config/kube_config_test.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 3384d272..7e054c0d 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -499,7 +499,7 @@ def safe_get(self, key): def __getitem__(self, key): v = self.safe_get(key) - if not v: + if v is None: raise ConfigException( 'Invalid kube-config file. Expected key %s in %s' % (key, self.name)) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index b29525e8..ff16ede8 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -564,13 +564,14 @@ class TestKubeConfigLoader(BaseTestCase): "server": TEST_SSL_HOST, "certificate-authority-data": TEST_CERTIFICATE_AUTH_BASE64, + "insecure-skip-tls-verify": False, } }, { "name": "no_ssl_verification", "cluster": { "server": TEST_SSL_HOST, - "insecure-skip-tls-verify": "true", + "insecure-skip-tls-verify": True, } }, ], @@ -1076,7 +1077,8 @@ def test_ssl(self): token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, cert_file=self._create_temp_file(TEST_CLIENT_CERT), key_file=self._create_temp_file(TEST_CLIENT_KEY), - ssl_ca_cert=self._create_temp_file(TEST_CERTIFICATE_AUTH) + ssl_ca_cert=self._create_temp_file(TEST_CERTIFICATE_AUTH), + verify_ssl=True ) actual = FakeConfig() KubeConfigLoader( From 4ea69211e3253930dcb0d9b588914d850770d4c7 Mon Sep 17 00:00:00 2001 From: Richard Godden <7768980+goddenrich@users.noreply.github.com> Date: Mon, 23 Sep 2019 17:43:20 +0100 Subject: [PATCH 036/141] added test that should fail --- config/kube_config_test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index b29525e8..7f11043c 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1353,13 +1353,17 @@ class TestKubeConfigMerger(BaseTestCase): }, ] } + TEST_KUBE_CONFIG_PART4 = { + "current-context": "no_user", + } def _create_multi_config(self): files = [] for part in ( self.TEST_KUBE_CONFIG_PART1, self.TEST_KUBE_CONFIG_PART2, - self.TEST_KUBE_CONFIG_PART3): + self.TEST_KUBE_CONFIG_PART3, + self.TEST_KUBE_CONFIG_PART4): files.append(self._create_temp_file(yaml.safe_dump(part))) return ENV_KUBECONFIG_PATH_SEPARATOR.join(files) From 7ce0198af22d105f3ae2dfbfeb99cb61384c7c63 Mon Sep 17 00:00:00 2001 From: Richard Godden <7768980+goddenrich@users.noreply.github.com> Date: Mon, 23 Sep 2019 17:57:50 +0100 Subject: [PATCH 037/141] default empty dict --- config/kube_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 9f3df7ea..cee71b2a 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -586,7 +586,7 @@ def load_config(self, path): self.config_merged = ConfigNode(path, config_merged, path) for item in ('clusters', 'contexts', 'users'): - self._merge(item, config[item], path) + self._merge(item, config.get(item, {}), path) self.config_files[path] = config def _merge(self, item, add_cfg, path): From 8ef5857bda7426760021f648ffe01cb8b6ec2304 Mon Sep 17 00:00:00 2001 From: Fabian von Feilitzsch Date: Tue, 24 Sep 2019 09:41:24 -0400 Subject: [PATCH 038/141] Use explicit API version for retrieving CRD API --- dynamic/test_client.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dynamic/test_client.py b/dynamic/test_client.py index dc52a30d..11546798 100644 --- a/dynamic/test_client.py +++ b/dynamic/test_client.py @@ -41,7 +41,9 @@ def test_cluster_custom_resources(self): changeme_api = client.resources.get( api_version='apps.example.com/v1', kind='ClusterChangeMe') - crd_api = client.resources.get(kind='CustomResourceDefinition') + crd_api = client.resources.get( + api_version='apiextensions.k8s.io/v1beta1', + kind='CustomResourceDefinition') name = 'clusterchangemes.apps.example.com' crd_manifest = { 'apiVersion': 'apiextensions.k8s.io/v1beta1', @@ -138,7 +140,9 @@ def test_namespaced_custom_resources(self): changeme_api = client.resources.get( api_version='apps.example.com/v1', kind='ChangeMe') - crd_api = client.resources.get(kind='CustomResourceDefinition') + crd_api = client.resources.get( + api_version='apiextensions.k8s.io/v1beta1', + kind='CustomResourceDefinition') name = 'changemes.apps.example.com' crd_manifest = { 'apiVersion': 'apiextensions.k8s.io/v1beta1', From a4f249b48a467e8c605c09a944a14d6eb6e62b9a Mon Sep 17 00:00:00 2001 From: Oz Tiram Date: Wed, 2 Oct 2019 20:26:14 +0200 Subject: [PATCH 039/141] Migrate to pytest travis --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 803390f2..2d92c46e 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,6 @@ envlist = py27, py35, py36, py37 passenv = TOXENV CI TRAVIS TRAVIS_* commands = python -V - pip install nose - ./run_tox.sh nosetests [] + pip install pytest + ./run_tox.sh pytest From 0b208334ef0247aad9afcaae8003954423b61a0d Mon Sep 17 00:00:00 2001 From: Nigel Foucha Date: Wed, 9 Oct 2019 11:37:40 -0400 Subject: [PATCH 040/141] Dynamically load apiserver id from kube config --- config/kube_config.py | 7 ++-- config/kube_config_test.py | 73 +++++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index ddfd0b38..469e8aee 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -249,12 +249,15 @@ def _refresh_azure_token(self, config): tenant = config['tenant-id'] authority = 'https://login.microsoftonline.com/%7B%7D'.format(tenant) context = adal.AuthenticationContext( - authority, validate_authority=True, + authority, validate_authority=True, api_version='1.0' ) refresh_token = config['refresh-token'] client_id = config['client-id'] + apiserver_id = config.get( + 'apiserver-id', + '00000002-0000-0000-c000-000000000000') token_response = context.acquire_token_with_refresh_token( - refresh_token, client_id, '00000002-0000-0000-c000-000000000000') + refresh_token, client_id, apiserver_id) provider = self._user['auth-provider']['config'] provider.value['access-token'] = token_response['accessToken'] diff --git a/config/kube_config_test.py b/config/kube_config_test.py index c8fb7967..fd00903e 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -457,6 +457,20 @@ class TestKubeConfigLoader(BaseTestCase): "user": "azure_str_error" } }, + { + "name": "azure_no_apiserver", + "context": { + "cluster": "default", + "user": "azure_no_apiserver" + } + }, + { + "name": "azure_bad_apiserver", + "context": { + "cluster": "default", + "user": "azure_bad_apiserver" + } + }, { "name": "expired_oidc", "context": { @@ -647,7 +661,7 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "ApiserverId", + "apiserver-id": "00000002-0000-0000-c000-000000000000", "environment": "AzurePublicCloud", "refresh-token": "refreshToken", "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" @@ -662,7 +676,7 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "ApiserverId", + "apiserver-id": "00000002-0000-0000-c000-000000000000", "environment": "AzurePublicCloud", "expires-in": "0", "expires-on": "156207275", @@ -679,7 +693,7 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "ApiserverId", + "apiserver-id": "00000002-0000-0000-c000-000000000000", "environment": "AzurePublicCloud", "expires-in": "0", "expires-on": "2018-10-18 00:52:29.044727", @@ -696,7 +710,7 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "ApiserverId", + "apiserver-id": "00000002-0000-0000-c000-000000000000", "environment": "AzurePublicCloud", "expires-in": "0", "expires-on": "2018-10-18 00:52", @@ -713,7 +727,7 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "ApiserverId", + "apiserver-id": "00000002-0000-0000-c000-000000000000", "environment": "AzurePublicCloud", "expires-in": "0", "expires-on": "-1", @@ -724,6 +738,39 @@ class TestKubeConfigLoader(BaseTestCase): } } }, + { + "name": "azure_no_apiserver", + "user": { + "auth-provider": { + "config": { + "access-token": TEST_AZURE_TOKEN, + "environment": "AzurePublicCloud", + "expires-in": "0", + "expires-on": "156207275", + "refresh-token": "refreshToken", + "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" + }, + "name": "azure" + } + } + }, + { + "name": "azure_bad_apiserver", + "user": { + "auth-provider": { + "config": { + "access-token": TEST_AZURE_TOKEN, + "apiserver-id": "ApiserverId", + "environment": "AzurePublicCloud", + "expires-in": "0", + "expires-on": "156207275", + "refresh-token": "refreshToken", + "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" + }, + "name": "azure" + } + } + }, { "name": "expired_oidc", "user": { @@ -1047,6 +1094,22 @@ def test_azure_with_expired_int_error(self): provider = loader._user['auth-provider'] self.assertRaises(ValueError, loader._azure_is_expired, provider) + def test_azure_with_no_apiserver(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="azure_no_apiserver", + ) + provider = loader._user['auth-provider'] + self.assertTrue(loader._azure_is_expired(provider)) + + def test_azure_with_bad_apiserver(self): + loader = KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="azure_bad_apiserver", + ) + provider = loader._user['auth-provider'] + self.assertTrue(loader._azure_is_expired(provider)) + def test_user_pass(self): expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN) actual = FakeConfig() From f11587d0e3cedf645ba12fe4dfd7971ee7162c9d Mon Sep 17 00:00:00 2001 From: Alexey Volkov Date: Thu, 17 Oct 2019 17:40:20 -0700 Subject: [PATCH 041/141] Fixed Watch.unmarshal_event when data is not a JSON-serialized object Fixes https://github.com/kubernetes-client/python/issues/982 Fixes https://github.com/kubernetes-client/python/issues/983 --- watch/watch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/watch/watch.py b/watch/watch.py index 77b97947..ba87de92 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -88,6 +88,8 @@ def unmarshal_event(self, data, return_type): js = json.loads(data) except ValueError: return data + if not (isinstance(js, dict) and 'object' in js): + return data js['raw_object'] = js['object'] if return_type: obj = SimpleNamespace(data=json.dumps(js['raw_object'])) From 5f395ba57b3923d51e9b4119b4ab32c4c1aa1c7a Mon Sep 17 00:00:00 2001 From: Nabarun Pal Date: Sun, 20 Oct 2019 00:33:39 +0530 Subject: [PATCH 042/141] Runs hack/* scripts as individual build step --- .travis.yml | 56 +++++++++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/.travis.yml b/.travis.yml index 70b1166a..8e576813 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,35 +2,41 @@ language: python dist: xenial -matrix: - include: - - python: 2.7 - env: TOXENV=py27 - - python: 2.7 - env: TOXENV=py27-functional - - python: 2.7 - env: TOXENV=update-pycodestyle - - python: 3.7 - env: TOXENV=docs - - python: 2.7 - env: TOXENV=coverage,codecov - - python: 3.5 - env: TOXENV=py35 - - python: 3.5 - env: TOXENV=py35-functional - - python: 3.6 - env: TOXENV=py36 - - python: 3.6 - env: TOXENV=py36-functional - - python: 3.7 - env: TOXENV=py37 - - python: 3.7 - env: TOXENV=py37-functional +stages: + - verify boilerplate + - test install: - pip install tox script: - ./run_tox.sh tox - - ./hack/verify-boilerplate.sh +jobs: + include: + - stage: verify boilerplate + script: ./hack/verify-boilerplate.sh + python: 3.7 + - stage: test + python: 2.7 + env: TOXENV=py27 + - python: 2.7 + env: TOXENV=py27-functional + - python: 2.7 + env: TOXENV=update-pycodestyle + - python: 3.7 + env: TOXENV=docs + - python: 2.7 + env: TOXENV=coverage,codecov + - python: 3.5 + env: TOXENV=py35 + - python: 3.5 + env: TOXENV=py35-functional + - python: 3.6 + env: TOXENV=py36 + - python: 3.6 + env: TOXENV=py36-functional + - python: 3.7 + env: TOXENV=py37 + - python: 3.7 + env: TOXENV=py37-functional From 4e84e7d4562c75617a06a733d5fa715637b9b912 Mon Sep 17 00:00:00 2001 From: Nabarun Pal Date: Tue, 22 Oct 2019 00:40:35 +0530 Subject: [PATCH 043/141] Adds Python 3.8 to the Travis CI test jobs --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index 8e576813..ddff691a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -40,3 +40,7 @@ jobs: env: TOXENV=py37 - python: 3.7 env: TOXENV=py37-functional + - python: 3.8 + env: TOXENV=py38 + - python: 3.8 + env: TOXENV=py38-functional From ced17c6881883ed5f07c3dc43e5d79d702bd73c1 Mon Sep 17 00:00:00 2001 From: Ulrik Mikaelsson Date: Tue, 26 Nov 2019 23:55:59 +0100 Subject: [PATCH 044/141] stream/ws_client: Use StringIO for WSClient._all bytes() += bytes() copies both buffers into a new one, causing exponential cost and gradual slow-down. Replacing with StringIO improves that --- stream/ws_client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 7f041206..775849d0 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -24,6 +24,7 @@ import yaml from six.moves.urllib.parse import urlencode, quote_plus, urlparse, urlunparse +from six import StringIO from websocket import WebSocket, ABNF, enableTrace @@ -47,7 +48,7 @@ def __init__(self, configuration, url, headers): header = [] self._connected = False self._channels = {} - self._all = "" + self._all = StringIO() # We just need to pass the Authorization, ignore all the other # http headers we get from the generated code @@ -157,8 +158,8 @@ def read_all(self): TODO: Maybe we can process this and return a more meaningful map with channels mapped for each input. """ - out = self._all - self._all = "" + out = self._all.getvalue() + self._all = self._all.__class__() self._channels = {} return out @@ -195,7 +196,7 @@ def update(self, timeout=0): if channel in [STDOUT_CHANNEL, STDERR_CHANNEL]: # keeping all messages in the order they received # for non-blocking call. - self._all += data + self._all.write(data) if channel not in self._channels: self._channels[channel] = data else: From 368d0d7b1e764adf5269b2f66ec0777331eccd82 Mon Sep 17 00:00:00 2001 From: Ulrik Mikaelsson Date: Wed, 27 Nov 2019 11:38:20 +0100 Subject: [PATCH 045/141] ws_client: Add option to disable capture-all --- stream/stream.py | 3 ++- stream/ws_client.py | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/stream/stream.py b/stream/stream.py index a9d0b402..6d5f05f8 100644 --- a/stream/stream.py +++ b/stream/stream.py @@ -16,7 +16,8 @@ def stream(func, *args, **kwargs): - """Stream given API call using websocket""" + """Stream given API call using websocket. + Extra kwarg: capture-all=True - captures all stdout+stderr for use with WSClient.read_all()""" def _intercept_request_call(*args, **kwargs): # old generated code's api client has config. new ones has diff --git a/stream/ws_client.py b/stream/ws_client.py index 775849d0..0a8426d9 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -34,9 +34,16 @@ ERROR_CHANNEL = 3 RESIZE_CHANNEL = 4 +class _IgnoredIO: + def write(self, _x): + pass + + def getvalue(self): + raise TypeError("Tried to read_all() from a WSClient configured to not capture. Did you mean `capture_all=True`?") + class WSClient: - def __init__(self, configuration, url, headers): + def __init__(self, configuration, url, headers, capture_all): """A websocket client with support for channels. Exec command uses different channels for different streams. for @@ -48,7 +55,10 @@ def __init__(self, configuration, url, headers): header = [] self._connected = False self._channels = {} - self._all = StringIO() + if capture_all: + self._all = StringIO() + else: + self._all = _IgnoredIO() # We just need to pass the Authorization, ignore all the other # http headers we get from the generated code @@ -258,6 +268,7 @@ def websocket_call(configuration, *args, **kwargs): url = args[1] _request_timeout = kwargs.get("_request_timeout", 60) _preload_content = kwargs.get("_preload_content", True) + capture_all = kwargs.get("capture_all", True) headers = kwargs.get("headers") # Expand command parameter list to indivitual command params @@ -273,7 +284,7 @@ def websocket_call(configuration, *args, **kwargs): url += '?' + urlencode(query_params) try: - client = WSClient(configuration, get_websocket_http://github.com/url(url), headers) + client = WSClient(configuration, get_websocket_http://github.com/url(url), headers, capture_all) if not _preload_content: return client client.run_forever(timeout=_request_timeout) From 39113de2aadff6bd5699fd4d9cc538ee1c9ccb90 Mon Sep 17 00:00:00 2001 From: Fabrice Rabaute Date: Thu, 3 Oct 2019 15:04:30 -0700 Subject: [PATCH 046/141] Add option to refresh gcp token when config is cmd-path --- config/kube_config.py | 73 ++++++++++++++++ config/kube_config_test.py | 165 ++++++++++++++++++++++++++++++++++--- 2 files changed, 228 insertions(+), 10 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 469e8aee..4701731b 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -20,8 +20,10 @@ import logging import os import platform +import subprocess import tempfile import time +from collections import namedtuple import google.auth import google.auth.transport.requests @@ -133,6 +135,46 @@ def as_data(self): return self._data +class CommandTokenSource(object): + def __init__(self, cmd, args, tokenKey, expiryKey): + self._cmd = cmd + self._args = args + if not tokenKey: + self._tokenKey = '{.access_token}' + else: + self._tokenKey = tokenKey + if not expiryKey: + self._expiryKey = '{.token_expiry}' + else: + self._expiryKey = expiryKey + + def token(self): + fullCmd = self._cmd + (" ") + " ".join(self._args) + process = subprocess.Popen( + [self._cmd] + self._args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) + (stdout, stderr) = process.communicate() + exit_code = process.wait() + if exit_code != 0: + msg = 'cmd-path: process returned %d' % exit_code + msg += "\nCmd: %s" % fullCmd + stderr = stderr.strip() + if stderr: + msg += '\nStderr: %s' % stderr + raise ConfigException(msg) + try: + data = json.loads(stdout) + except ValueError as de: + raise ConfigException( + 'exec: failed to decode process output: %s' % de) + A = namedtuple('A', ['token', 'expiry']) + return A( + token=data['credential']['access_token'], + expiry=parse_rfc3339(data['credential']['token_expiry'])) + + class KubeConfigLoader(object): def __init__(self, config_dict, active_context=None, @@ -156,7 +198,38 @@ def __init__(self, config_dict, active_context=None, self._config_base_path = config_base_path self._config_persister = config_persister + def _refresh_credentials_with_cmd_path(): + config = self._user['auth-provider']['config'] + cmd = config['cmd-path'] + if len(cmd) == 0: + raise ConfigException( + 'missing access token cmd ' + '(cmd-path is an empty string in your kubeconfig file)') + if 'scopes' in config and config['scopes'] != "": + raise ConfigException( + 'scopes can only be used ' + 'when kubectl is using a gcp service account key') + args = [] + if 'cmd-args' in config: + args = config['cmd-args'].split() + else: + fields = config['cmd-path'].split() + cmd = fields[0] + args = fields[1:] + + commandTokenSource = CommandTokenSource( + cmd, args, + config.safe_get('token-key'), + config.safe_get('expiry-key')) + return commandTokenSource.token() + def _refresh_credentials(): + # Refresh credentials using cmd-path + if ('auth-provider' in self._user and + 'config' in self._user['auth-provider'] and + 'cmd-path' in self._user['auth-provider']['config']): + return _refresh_credentials_with_cmd_path() + credentials, project_id = google.auth.default(scopes=[ 'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/userinfo.email' diff --git a/config/kube_config_test.py b/config/kube_config_test.py index fd00903e..d4e67683 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -19,6 +19,7 @@ import shutil import tempfile import unittest +from collections import namedtuple import mock import yaml @@ -27,9 +28,11 @@ from kubernetes.client import Configuration from .config_exception import ConfigException -from .kube_config import (ENV_KUBECONFIG_PATH_SEPARATOR, ConfigNode, - FileOrData, KubeConfigLoader, KubeConfigMerger, - _cleanup_temp_files, _create_temp_file_with_content, +from .dateutil import parse_rfc3339 +from .kube_config import (ENV_KUBECONFIG_PATH_SEPARATOR, CommandTokenSource, + ConfigNode, FileOrData, KubeConfigLoader, + KubeConfigMerger, _cleanup_temp_files, + _create_temp_file_with_content, list_kube_config_contexts, load_kube_config, new_client_from_config) @@ -550,6 +553,27 @@ class TestKubeConfigLoader(BaseTestCase): "user": "exec_cred_user" } }, + { + "name": "contexttestcmdpath", + "context": { + "cluster": "clustertestcmdpath", + "user": "usertestcmdpath" + } + }, + { + "name": "contexttestcmdpathempty", + "context": { + "cluster": "clustertestcmdpath", + "user": "usertestcmdpathempty" + } + }, + { + "name": "contexttestcmdpathscope", + "context": { + "cluster": "clustertestcmdpath", + "user": "usertestcmdpathscope" + } + } ], "clusters": [ { @@ -588,6 +612,10 @@ class TestKubeConfigLoader(BaseTestCase): "insecure-skip-tls-verify": True, } }, + { + "name": "clustertestcmdpath", + "cluster": {} + } ], "users": [ { @@ -661,7 +689,8 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "00000002-0000-0000-c000-000000000000", + "apiserver-id": "00000002-0000-0000-c000-" + "000000000000", "environment": "AzurePublicCloud", "refresh-token": "refreshToken", "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" @@ -676,7 +705,8 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "00000002-0000-0000-c000-000000000000", + "apiserver-id": "00000002-0000-0000-c000-" + "000000000000", "environment": "AzurePublicCloud", "expires-in": "0", "expires-on": "156207275", @@ -693,7 +723,8 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "00000002-0000-0000-c000-000000000000", + "apiserver-id": "00000002-0000-0000-c000-" + "000000000000", "environment": "AzurePublicCloud", "expires-in": "0", "expires-on": "2018-10-18 00:52:29.044727", @@ -710,7 +741,8 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "00000002-0000-0000-c000-000000000000", + "apiserver-id": "00000002-0000-0000-c000-" + "000000000000", "environment": "AzurePublicCloud", "expires-in": "0", "expires-on": "2018-10-18 00:52", @@ -727,7 +759,8 @@ class TestKubeConfigLoader(BaseTestCase): "auth-provider": { "config": { "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "00000002-0000-0000-c000-000000000000", + "apiserver-id": "00000002-0000-0000-c000-" + "000000000000", "environment": "AzurePublicCloud", "expires-in": "0", "expires-on": "-1", @@ -877,6 +910,40 @@ class TestKubeConfigLoader(BaseTestCase): } } }, + { + "name": "usertestcmdpath", + "user": { + "auth-provider": { + "name": "gcp", + "config": { + "cmd-path": "cmdtorun" + } + } + } + }, + { + "name": "usertestcmdpathempty", + "user": { + "auth-provider": { + "name": "gcp", + "config": { + "cmd-path": "" + } + } + } + }, + { + "name": "usertestcmdpathscope", + "user": { + "auth-provider": { + "name": "gcp", + "config": { + "cmd-path": "cmd", + "scopes": "scope" + } + } + } + } ] } @@ -1279,6 +1346,48 @@ def test_user_exec_auth(self, mock): active_context="exec_cred_user").load_and_set(actual) self.assertEqual(expected, actual) + def test_user_cmd_path(self): + A = namedtuple('A', ['token', 'expiry']) + token = "dummy" + return_value = A(token, parse_rfc3339(datetime.datetime.now())) + CommandTokenSource.token = mock.Mock(return_value=return_value) + expected = FakeConfig(api_key={ + "authorization": BEARER_TOKEN_FORMAT % token}) + actual = FakeConfig() + KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="contexttestcmdpath").load_and_set(actual) + del actual.get_api_key_with_prefix + self.assertEqual(expected, actual) + + def test_user_cmd_path_empty(self): + A = namedtuple('A', ['token', 'expiry']) + token = "dummy" + return_value = A(token, parse_rfc3339(datetime.datetime.now())) + CommandTokenSource.token = mock.Mock(return_value=return_value) + expected = FakeConfig(api_key={ + "authorization": BEARER_TOKEN_FORMAT % token}) + actual = FakeConfig() + self.expect_exception(lambda: KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="contexttestcmdpathempty").load_and_set(actual), + "missing access token cmd " + "(cmd-path is an empty string in your kubeconfig file)") + + def test_user_cmd_path_with_scope(self): + A = namedtuple('A', ['token', 'expiry']) + token = "dummy" + return_value = A(token, parse_rfc3339(datetime.datetime.now())) + CommandTokenSource.token = mock.Mock(return_value=return_value) + expected = FakeConfig(api_key={ + "authorization": BEARER_TOKEN_FORMAT % token}) + actual = FakeConfig() + self.expect_exception(lambda: KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="contexttestcmdpathscope").load_and_set(actual), + "scopes can only be used when kubectl is using " + "a gcp service account key") + class TestKubernetesClientConfiguration(BaseTestCase): # Verifies properties of kubernetes.client.Configuration. @@ -1421,6 +1530,37 @@ class TestKubeConfigMerger(BaseTestCase): TEST_KUBE_CONFIG_PART4 = { "current-context": "no_user", } + # Config with user having cmd-path + TEST_KUBE_CONFIG_PART5 = { + "contexts": [ + { + "name": "contexttestcmdpath", + "context": { + "cluster": "clustertestcmdpath", + "user": "usertestcmdpath" + } + } + ], + "clusters": [ + { + "name": "clustertestcmdpath", + "cluster": {} + } + ], + "users": [ + { + "name": "usertestcmdpath", + "user": { + "auth-provider": { + "name": "gcp", + "config": { + "cmd-path": "cmdtorun" + } + } + } + } + ] + } def _create_multi_config(self): files = [] @@ -1428,7 +1568,8 @@ def _create_multi_config(self): self.TEST_KUBE_CONFIG_PART1, self.TEST_KUBE_CONFIG_PART2, self.TEST_KUBE_CONFIG_PART3, - self.TEST_KUBE_CONFIG_PART4): + self.TEST_KUBE_CONFIG_PART4, + self.TEST_KUBE_CONFIG_PART5): files.append(self._create_temp_file(yaml.safe_dump(part))) return ENV_KUBECONFIG_PATH_SEPARATOR.join(files) @@ -1439,7 +1580,11 @@ def test_list_kube_config_contexts(self): {'context': {'cluster': 'ssl', 'user': 'ssl'}, 'name': 'ssl'}, {'context': {'cluster': 'default', 'user': 'simple_token'}, 'name': 'simple_token'}, - {'context': {'cluster': 'default', 'user': 'expired_oidc'}, 'name': 'expired_oidc'}] + {'context': {'cluster': 'default', 'user': 'expired_oidc'}, + 'name': 'expired_oidc'}, + {'context': {'cluster': 'clustertestcmdpath', + 'user': 'usertestcmdpath'}, + 'name': 'contexttestcmdpath'}] contexts, active_context = list_kube_config_contexts( config_file=kubeconfigs) From 64662bb738e33d926a7553dda2ca8f301e14a2a9 Mon Sep 17 00:00:00 2001 From: Fabrice Rabaute Date: Fri, 4 Oct 2019 16:52:20 -0700 Subject: [PATCH 047/141] config: Fix persist_config flag and function calls The persist_config flag was setting the kwargs['config_persister'] to the result of the function kcfg.save_changes and not the function kcfg.save_changes itself. Once this is fixed, the second problem was that the function was called with an argument when it's defined without argument so an exception was raised. --- config/kube_config.py | 8 ++++---- config/kube_config_test.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 4701731b..53a0d755 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -336,7 +336,7 @@ def _refresh_azure_token(self, config): provider.value['access-token'] = token_response['accessToken'] provider.value['expires-on'] = token_response['expiresOn'] if self._config_persister: - self._config_persister(self._config.value) + self._config_persister() def _load_gcp_token(self, provider): if (('config' not in provider) or @@ -357,7 +357,7 @@ def _refresh_gcp_token(self): provider.value['access-token'] = credentials.token provider.value['expiry'] = format_rfc3339(credentials.expiry) if self._config_persister: - self._config_persister(self._config.value) + self._config_persister() def _load_oid_token(self, provider): if 'config' not in provider: @@ -398,7 +398,7 @@ def _load_oid_token(self, provider): self._refresh_oidc(provider) if self._config_persister: - self._config_persister(self._config.value) + self._config_persister() self.token = "Bearer %s" % provider['config']['id-token'] @@ -691,7 +691,7 @@ def _get_kube_config_loader_for_yaml_file( kcfg = KubeConfigMerger(filename) if persist_config and 'config_persister' not in kwargs: - kwargs['config_persister'] = kcfg.save_changes() + kwargs['config_persister'] = kcfg.save_changes if kcfg.config is None: raise ConfigException( diff --git a/config/kube_config_test.py b/config/kube_config_test.py index d4e67683..b05ad9e3 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -33,6 +33,7 @@ ConfigNode, FileOrData, KubeConfigLoader, KubeConfigMerger, _cleanup_temp_files, _create_temp_file_with_content, + _get_kube_config_loader_for_yaml_file, list_kube_config_contexts, load_kube_config, new_client_from_config) @@ -1388,6 +1389,24 @@ def test_user_cmd_path_with_scope(self): "scopes can only be used when kubectl is using " "a gcp service account key") + def test__get_kube_config_loader_for_yaml_file_no_persist(self): + expected = FakeConfig(host=TEST_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) + config_file = self._create_temp_file( + yaml.safe_dump(self.TEST_KUBE_CONFIG)) + actual = _get_kube_config_loader_for_yaml_file(config_file) + self.assertIsNone(actual._config_persister) + + def test__get_kube_config_loader_for_yaml_file_persist(self): + expected = FakeConfig(host=TEST_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) + config_file = self._create_temp_file( + yaml.safe_dump(self.TEST_KUBE_CONFIG)) + actual = _get_kube_config_loader_for_yaml_file(config_file, + persist_config=True) + self.assertTrue(callable(actual._config_persister)) + self.assertEquals(actual._config_persister.__name__, "save_changes") + class TestKubernetesClientConfiguration(BaseTestCase): # Verifies properties of kubernetes.client.Configuration. From faf2e0c2db712ad9bbf9709f56038994a2997c24 Mon Sep 17 00:00:00 2001 From: Nigel Foucha Date: Thu, 20 Feb 2020 19:20:44 -0500 Subject: [PATCH 048/141] Fix apiserver_id 'get' method --- config/kube_config.py | 9 ++++-- config/kube_config_test.py | 63 -------------------------------------- 2 files changed, 6 insertions(+), 66 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 4701731b..97f9371f 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -326,9 +326,12 @@ def _refresh_azure_token(self, config): ) refresh_token = config['refresh-token'] client_id = config['client-id'] - apiserver_id = config.get( - 'apiserver-id', - '00000002-0000-0000-c000-000000000000') + apiserver_id = '00000002-0000-0000-c000-000000000000' + try: + apiserver_id = config['apiserver-id'] + except ConfigException: + # We've already set a default above + pass token_response = context.acquire_token_with_refresh_token( refresh_token, client_id, apiserver_id) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index d4e67683..aa0b9641 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -460,20 +460,6 @@ class TestKubeConfigLoader(BaseTestCase): "user": "azure_str_error" } }, - { - "name": "azure_no_apiserver", - "context": { - "cluster": "default", - "user": "azure_no_apiserver" - } - }, - { - "name": "azure_bad_apiserver", - "context": { - "cluster": "default", - "user": "azure_bad_apiserver" - } - }, { "name": "expired_oidc", "context": { @@ -771,39 +757,6 @@ class TestKubeConfigLoader(BaseTestCase): } } }, - { - "name": "azure_no_apiserver", - "user": { - "auth-provider": { - "config": { - "access-token": TEST_AZURE_TOKEN, - "environment": "AzurePublicCloud", - "expires-in": "0", - "expires-on": "156207275", - "refresh-token": "refreshToken", - "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" - }, - "name": "azure" - } - } - }, - { - "name": "azure_bad_apiserver", - "user": { - "auth-provider": { - "config": { - "access-token": TEST_AZURE_TOKEN, - "apiserver-id": "ApiserverId", - "environment": "AzurePublicCloud", - "expires-in": "0", - "expires-on": "156207275", - "refresh-token": "refreshToken", - "tenant-id": "9d2ac018-e843-4e14-9e2b-4e0ddac75433" - }, - "name": "azure" - } - } - }, { "name": "expired_oidc", "user": { @@ -1161,22 +1114,6 @@ def test_azure_with_expired_int_error(self): provider = loader._user['auth-provider'] self.assertRaises(ValueError, loader._azure_is_expired, provider) - def test_azure_with_no_apiserver(self): - loader = KubeConfigLoader( - config_dict=self.TEST_KUBE_CONFIG, - active_context="azure_no_apiserver", - ) - provider = loader._user['auth-provider'] - self.assertTrue(loader._azure_is_expired(provider)) - - def test_azure_with_bad_apiserver(self): - loader = KubeConfigLoader( - config_dict=self.TEST_KUBE_CONFIG, - active_context="azure_bad_apiserver", - ) - provider = loader._user['auth-provider'] - self.assertTrue(loader._azure_is_expired(provider)) - def test_user_pass(self): expected = FakeConfig(host=TEST_HOST, token=TEST_BASIC_TOKEN) actual = FakeConfig() From ea11e44daf06e38a9b49c5f8cc41f19447d79bfe Mon Sep 17 00:00:00 2001 From: Haowei Cai Date: Fri, 21 Feb 2020 15:19:35 -0800 Subject: [PATCH 049/141] watch stream: stop unmarshalling when streaming log --- watch/watch.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/watch/watch.py b/watch/watch.py index ba87de92..fe7a9247 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -84,12 +84,7 @@ def get_watch_argument_name(self, func): return 'watch' def unmarshal_event(self, data, return_type): - try: - js = json.loads(data) - except ValueError: - return data - if not (isinstance(js, dict) and 'object' in js): - return data + js = json.loads(data) js['raw_object'] = js['object'] if return_type: obj = SimpleNamespace(data=json.dumps(js['raw_object'])) @@ -132,7 +127,8 @@ def stream(self, func, *args, **kwargs): self._stop = False return_type = self.get_return_type(func) - kwargs[self.get_watch_argument_name(func)] = True + watch_arg = self.get_watch_argument_name(func) + kwargs[watch_arg] = True kwargs['_preload_content'] = False if 'resource_version' in kwargs: self.resource_version = kwargs['resource_version'] @@ -142,7 +138,12 @@ def stream(self, func, *args, **kwargs): resp = func(*args, **kwargs) try: for line in iter_resp_lines(resp): - yield self.unmarshal_event(line, return_type) + # unmarshal when we are receiving events from watch, + # return raw string when we are streaming log + if watch_arg == "watch": + yield self.unmarshal_event(line, return_type) + else: + yield line if self._stop: break finally: From fb259e1477997c8b16b8f8a09c410e7a506fd39c Mon Sep 17 00:00:00 2001 From: Fabian von Feilitzsch Date: Fri, 13 Mar 2020 15:04:04 -0400 Subject: [PATCH 050/141] Prevent 503s from killing the client during discovery --- dynamic/discovery.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/dynamic/discovery.py b/dynamic/discovery.py index 9468a274..24d48d81 100644 --- a/dynamic/discovery.py +++ b/dynamic/discovery.py @@ -23,7 +23,7 @@ from urllib3.exceptions import ProtocolError, MaxRetryError from kubernetes import __version__ -from .exceptions import NotFoundError, ResourceNotFoundError, ResourceNotUniqueError, ApiException +from .exceptions import NotFoundError, ResourceNotFoundError, ResourceNotUniqueError, ApiException, ServiceUnavailableError from .resource import Resource, ResourceList @@ -155,7 +155,10 @@ def get_resources_for_api_version(self, prefix, group, version, preferred): subresources = {} path = '/'.join(filter(None, [prefix, group, version])) - resources_response = self.client.request('GET', path).resources or [] + try: + resources_response = self.client.request('GET', path).resources or [] + except ServiceUnavailableError: + resources_response = [] resources_raw = list(filter(lambda resource: '/' not in resource['name'], resources_response)) subresources_raw = list(filter(lambda resource: '/' in resource['name'], resources_response)) @@ -251,13 +254,11 @@ def __search(self, parts, resources, reqParams): # Check if we've requested resources for this group if not resourcePart.resources: prefix, group, version = reqParams[0], reqParams[1], part - try: - resourcePart.resources = self.get_resources_for_api_version(prefix, - group, part, resourcePart.preferred) - except NotFoundError: - raise ResourceNotFoundError + resourcePart.resources = self.get_resources_for_api_version( + prefix, group, part, resourcePart.preferred) + self._cache['resources'][prefix][group][version] = resourcePart - self.__update_cache=True + self.__update_cache = True return self.__search(parts[1:], resourcePart.resources, reqParams) elif isinstance(resourcePart, dict): # In this case parts [0] will be a specified prefix, group, version From 3cfc41ea9e94ca6c865d4149dcd64f24412221ce Mon Sep 17 00:00:00 2001 From: Shihang Zhang Date: Sun, 12 Apr 2020 12:03:22 -0700 Subject: [PATCH 051/141] set expiration on token of incluster config and reload if expires --- config/incluster_config.py | 35 ++++++++++++++++++++++++--------- config/incluster_config_test.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/config/incluster_config.py b/config/incluster_config.py index 6f28a4ae..80853c28 100644 --- a/config/incluster_config.py +++ b/config/incluster_config.py @@ -13,6 +13,7 @@ # limitations under the License. import os +import datetime from kubernetes.client import Configuration @@ -40,10 +41,11 @@ def __init__(self, token_filename, self._token_filename = token_filename self._cert_filename = cert_filename self._environ = environ + self._token_refresh_period = datetime.timedelta(minutes=1) - def load_and_set(self): + def load_and_set(self, refresh_token=True): self._load_config() - self._set_config() + self._set_config(refresh_token=refresh_token) def _load_config(self): if (SERVICE_HOST_ENV_NAME not in self._environ or @@ -61,10 +63,7 @@ def _load_config(self): if not os.path.isfile(self._token_filename): raise ConfigException("Service token file does not exists.") - with open(self._token_filename) as f: - self.token = f.read() - if not self.token: - raise ConfigException("Token file exists but empty.") + self._read_token_file() if not os.path.isfile(self._cert_filename): raise ConfigException( @@ -76,19 +75,37 @@ def _load_config(self): self.ssl_ca_cert = self._cert_filename - def _set_config(self): + def _set_config(self, refresh_token): configuration = Configuration() configuration.host = self.host configuration.ssl_ca_cert = self.ssl_ca_cert configuration.api_key['authorization'] = "bearer " + self.token Configuration.set_default(configuration) + if not refresh_token: + return + def wrap(f): + in_cluster_config = self + def wrapped(self, identifier): + if identifier == 'authorization' and identifier in self.api_key and in_cluster_config.token_expires_at <= datetime.datetime.now(): + in_cluster_config._read_token_file() + self.api_key[identifier] = "bearer " + in_cluster_config.token + return f(self, identifier) + return wrapped + Configuration.get_api_key_with_prefix = wrap(Configuration.get_api_key_with_prefix) + + def _read_token_file(self): + with open(self._token_filename) as f: + self.token = f.read() + self.token_expires_at = datetime.datetime.now() + self._token_refresh_period + if not self.token: + raise ConfigException("Token file exists but empty.") -def load_incluster_config(): +def load_incluster_config(refresh_token=True): """ Use the service account kubernetes gives to pods to connect to kubernetes cluster. It's intended for clients that expect to be running inside a pod running on kubernetes. It will raise an exception if called from a process not running in a kubernetes environment.""" InClusterConfigLoader(token_filename=SERVICE_TOKEN_FILENAME, - cert_filename=SERVICE_CERT_FILENAME).load_and_set() + cert_filename=SERVICE_CERT_FILENAME).load_and_set(refresh_token=refresh_token) diff --git a/config/incluster_config_test.py b/config/incluster_config_test.py index 622b31b3..e5698021 100644 --- a/config/incluster_config_test.py +++ b/config/incluster_config_test.py @@ -15,12 +15,17 @@ import os import tempfile import unittest +import datetime +import time + +from kubernetes.client import Configuration from .config_exception import ConfigException from .incluster_config import (SERVICE_HOST_ENV_NAME, SERVICE_PORT_ENV_NAME, InClusterConfigLoader, _join_host_port) _TEST_TOKEN = "temp_token" +_TEST_NEW_TOKEN = "temp_new_token" _TEST_CERT = "temp_cert" _TEST_HOST = "127.0.0.1" _TEST_PORT = "80" @@ -50,6 +55,12 @@ def _create_file_with_temp_content(self, content=""): os.close(handler) return name + def _overwrite_file_with_content(self, name, content=""): + handler = os.open(name, os.O_RDWR) + os.truncate(name, 0) + os.write(handler, str.encode(content)) + os.close(handler) + def get_test_loader( self, token_filename=None, @@ -78,6 +89,25 @@ def test_load_config(self): self.assertEqual(cert_filename, loader.ssl_ca_cert) self.assertEqual(_TEST_TOKEN, loader.token) + def test_refresh_token(self): + loader = self.get_test_loader() + loader._token_refresh_period = datetime.timedelta(seconds=5) + loader.load_and_set() + config = Configuration() + + self.assertEqual('bearer '+_TEST_TOKEN, config.get_api_key_with_prefix('authorization')) + self.assertEqual(_TEST_TOKEN, loader.token) + self.assertIsNotNone(loader.token_expires_at) + + old_token = loader.token + old_token_expires_at = loader.token_expires_at + self._overwrite_file_with_content(loader._token_filename, _TEST_NEW_TOKEN) + time.sleep(5) + + self.assertEqual('bearer '+_TEST_NEW_TOKEN, config.get_api_key_with_prefix('authorization')) + self.assertEqual(_TEST_NEW_TOKEN, loader.token) + self.assertGreater(loader.token_expires_at, old_token_expires_at) + def _should_fail_load(self, config_loader, reason): try: config_loader.load_and_set() From 0b1ac8047036ed7cae8d3a001bf3b2389869b4e3 Mon Sep 17 00:00:00 2001 From: Shihang Zhang Date: Tue, 12 May 2020 14:46:06 -0700 Subject: [PATCH 052/141] allow incluster to accept pass-in config --- config/incluster_config.py | 80 ++++++++++++++++++--------------- config/incluster_config_test.py | 78 ++++++++++++++++---------------- 2 files changed, 85 insertions(+), 73 deletions(-) diff --git a/config/incluster_config.py b/config/incluster_config.py index 80853c28..288a2688 100644 --- a/config/incluster_config.py +++ b/config/incluster_config.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os import datetime +import os from kubernetes.client import Configuration @@ -35,30 +35,39 @@ def _join_host_port(host, port): class InClusterConfigLoader(object): - - def __init__(self, token_filename, - cert_filename, environ=os.environ): + def __init__(self, + token_filename, + cert_filename, + try_refresh_token=True, + environ=os.environ): self._token_filename = token_filename self._cert_filename = cert_filename self._environ = environ + self._try_refresh_token = try_refresh_token self._token_refresh_period = datetime.timedelta(minutes=1) - def load_and_set(self, refresh_token=True): + def load_and_set(self, client_configuration=None): + try_set_default = False + if client_configuration is None: + client_configuration = type.__call__(Configuration) + try_set_default = True self._load_config() - self._set_config(refresh_token=refresh_token) + self._set_config(client_configuration) + if try_set_default: + Configuration.set_default(client_configuration) def _load_config(self): - if (SERVICE_HOST_ENV_NAME not in self._environ or - SERVICE_PORT_ENV_NAME not in self._environ): + if (SERVICE_HOST_ENV_NAME not in self._environ + or SERVICE_PORT_ENV_NAME not in self._environ): raise ConfigException("Service host/port is not set.") - if (not self._environ[SERVICE_HOST_ENV_NAME] or - not self._environ[SERVICE_PORT_ENV_NAME]): + if (not self._environ[SERVICE_HOST_ENV_NAME] + or not self._environ[SERVICE_PORT_ENV_NAME]): raise ConfigException("Service host/port is set but empty.") - self.host = ( - "https://" + _join_host_port(self._environ[SERVICE_HOST_ENV_NAME], - self._environ[SERVICE_PORT_ENV_NAME])) + self.host = ("https://" + + _join_host_port(self._environ[SERVICE_HOST_ENV_NAME], + self._environ[SERVICE_PORT_ENV_NAME])) if not os.path.isfile(self._token_filename): raise ConfigException("Service token file does not exists.") @@ -75,37 +84,38 @@ def _load_config(self): self.ssl_ca_cert = self._cert_filename - def _set_config(self, refresh_token): - configuration = Configuration() - configuration.host = self.host - configuration.ssl_ca_cert = self.ssl_ca_cert - configuration.api_key['authorization'] = "bearer " + self.token - Configuration.set_default(configuration) - if not refresh_token: + def _set_config(self, client_configuration): + client_configuration.host = self.host + client_configuration.ssl_ca_cert = self.ssl_ca_cert + if self.token is not None: + client_configuration.api_key['authorization'] = self.token + if not self._try_refresh_token: return - def wrap(f): - in_cluster_config = self - def wrapped(self, identifier): - if identifier == 'authorization' and identifier in self.api_key and in_cluster_config.token_expires_at <= datetime.datetime.now(): - in_cluster_config._read_token_file() - self.api_key[identifier] = "bearer " + in_cluster_config.token - return f(self, identifier) - return wrapped - Configuration.get_api_key_with_prefix = wrap(Configuration.get_api_key_with_prefix) + + def load_token_from_file(*args): + if self.token_expires_at <= datetime.datetime.now(): + self._read_token_file() + return self.token + + client_configuration.get_api_key_with_prefix = load_token_from_file def _read_token_file(self): with open(self._token_filename) as f: - self.token = f.read() - self.token_expires_at = datetime.datetime.now() + self._token_refresh_period - if not self.token: + content = f.read() + if not content: raise ConfigException("Token file exists but empty.") + self.token = "bearer " + content + self.token_expires_at = datetime.datetime.now( + ) + self._token_refresh_period -def load_incluster_config(refresh_token=True): +def load_incluster_config(client_configuration=None, try_refresh_token=True): """ Use the service account kubernetes gives to pods to connect to kubernetes cluster. It's intended for clients that expect to be running inside a pod running on kubernetes. It will raise an exception if called from a process not running in a kubernetes environment.""" - InClusterConfigLoader(token_filename=SERVICE_TOKEN_FILENAME, - cert_filename=SERVICE_CERT_FILENAME).load_and_set(refresh_token=refresh_token) + InClusterConfigLoader( + token_filename=SERVICE_TOKEN_FILENAME, + cert_filename=SERVICE_CERT_FILENAME, + try_refresh_token=try_refresh_token).load_and_set(client_configuration) diff --git a/config/incluster_config_test.py b/config/incluster_config_test.py index e5698021..ef7468d7 100644 --- a/config/incluster_config_test.py +++ b/config/incluster_config_test.py @@ -12,11 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import os import tempfile -import unittest -import datetime import time +import unittest from kubernetes.client import Configuration @@ -33,14 +33,17 @@ _TEST_IPV6_HOST = "::1" _TEST_IPV6_HOST_PORT = "[::1]:80" -_TEST_ENVIRON = {SERVICE_HOST_ENV_NAME: _TEST_HOST, - SERVICE_PORT_ENV_NAME: _TEST_PORT} -_TEST_IPV6_ENVIRON = {SERVICE_HOST_ENV_NAME: _TEST_IPV6_HOST, - SERVICE_PORT_ENV_NAME: _TEST_PORT} +_TEST_ENVIRON = { + SERVICE_HOST_ENV_NAME: _TEST_HOST, + SERVICE_PORT_ENV_NAME: _TEST_PORT +} +_TEST_IPV6_ENVIRON = { + SERVICE_HOST_ENV_NAME: _TEST_IPV6_HOST, + SERVICE_PORT_ENV_NAME: _TEST_PORT +} class InClusterConfigTest(unittest.TestCase): - def setUp(self): self._temp_files = [] @@ -55,25 +58,18 @@ def _create_file_with_temp_content(self, content=""): os.close(handler) return name - def _overwrite_file_with_content(self, name, content=""): - handler = os.open(name, os.O_RDWR) - os.truncate(name, 0) - os.write(handler, str.encode(content)) - os.close(handler) - - def get_test_loader( - self, - token_filename=None, - cert_filename=None, - environ=_TEST_ENVIRON): + def get_test_loader(self, + token_filename=None, + cert_filename=None, + environ=_TEST_ENVIRON): if not token_filename: token_filename = self._create_file_with_temp_content(_TEST_TOKEN) if not cert_filename: cert_filename = self._create_file_with_temp_content(_TEST_CERT) - return InClusterConfigLoader( - token_filename=token_filename, - cert_filename=cert_filename, - environ=environ) + return InClusterConfigLoader(token_filename=token_filename, + cert_filename=cert_filename, + try_refresh_token=True, + environ=environ) def test_join_host_port(self): self.assertEqual(_TEST_HOST_PORT, @@ -87,25 +83,29 @@ def test_load_config(self): loader._load_config() self.assertEqual("https://" + _TEST_HOST_PORT, loader.host) self.assertEqual(cert_filename, loader.ssl_ca_cert) - self.assertEqual(_TEST_TOKEN, loader.token) + self.assertEqual('bearer ' + _TEST_TOKEN, loader.token) def test_refresh_token(self): loader = self.get_test_loader() - loader._token_refresh_period = datetime.timedelta(seconds=5) - loader.load_and_set() config = Configuration() + loader.load_and_set(config) - self.assertEqual('bearer '+_TEST_TOKEN, config.get_api_key_with_prefix('authorization')) - self.assertEqual(_TEST_TOKEN, loader.token) + self.assertEqual('bearer ' + _TEST_TOKEN, + config.get_api_key_with_prefix('authorization')) + self.assertEqual('bearer ' + _TEST_TOKEN, loader.token) self.assertIsNotNone(loader.token_expires_at) old_token = loader.token old_token_expires_at = loader.token_expires_at - self._overwrite_file_with_content(loader._token_filename, _TEST_NEW_TOKEN) - time.sleep(5) - - self.assertEqual('bearer '+_TEST_NEW_TOKEN, config.get_api_key_with_prefix('authorization')) - self.assertEqual(_TEST_NEW_TOKEN, loader.token) + loader._token_filename = self._create_file_with_temp_content( + _TEST_NEW_TOKEN) + self.assertEqual('bearer ' + _TEST_TOKEN, + config.get_api_key_with_prefix('authorization')) + + loader.token_expires_at = datetime.datetime.now() + self.assertEqual('bearer ' + _TEST_NEW_TOKEN, + config.get_api_key_with_prefix('authorization')) + self.assertEqual('bearer ' + _TEST_NEW_TOKEN, loader.token) self.assertGreater(loader.token_expires_at, old_token_expires_at) def _should_fail_load(self, config_loader, reason): @@ -122,9 +122,10 @@ def test_no_port(self): self._should_fail_load(loader, "no port specified") def test_empty_port(self): - loader = self.get_test_loader( - environ={SERVICE_HOST_ENV_NAME: _TEST_HOST, - SERVICE_PORT_ENV_NAME: ""}) + loader = self.get_test_loader(environ={ + SERVICE_HOST_ENV_NAME: _TEST_HOST, + SERVICE_PORT_ENV_NAME: "" + }) self._should_fail_load(loader, "empty port specified") def test_no_host(self): @@ -133,9 +134,10 @@ def test_no_host(self): self._should_fail_load(loader, "no host specified") def test_empty_host(self): - loader = self.get_test_loader( - environ={SERVICE_HOST_ENV_NAME: "", - SERVICE_PORT_ENV_NAME: _TEST_PORT}) + loader = self.get_test_loader(environ={ + SERVICE_HOST_ENV_NAME: "", + SERVICE_PORT_ENV_NAME: _TEST_PORT + }) self._should_fail_load(loader, "empty host specified") def test_no_cert_file(self): From ab515103d8f33d80e1b0e6c2a995d686bee66445 Mon Sep 17 00:00:00 2001 From: vishnu667 Date: Thu, 14 May 2020 07:44:54 +0530 Subject: [PATCH 053/141] Adding ability to pass kube_config as a dict. --- config/kube_config.py | 57 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 43676728..f82265c0 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -706,13 +706,34 @@ def _get_kube_config_loader_for_yaml_file( config_base_path=None, **kwargs) +def _get_kube_config_loader( + filename=None,config_dict=None, persist_config=False, **kwargs): + + if (config_dict is None): + kcfg = KubeConfigMerger(filename) + if persist_config and 'config_persister' not in kwargs: + kwargs['config_persister'] = kcfg.save_changes + + if kcfg.config is None: + raise ConfigException( + 'Invalid kube-config file. ' + 'No configuration found.') + return KubeConfigLoader( + config_dict=kcfg.config, + config_base_path=None, + **kwargs) + else: + return KubeConfigLoader( + config_dict=config_dict, + config_base_path=None, + **kwargs) def list_kube_config_contexts(config_file=None): if config_file is None: config_file = KUBE_CONFIG_DEFAULT_LOCATION - loader = _get_kube_config_loader_for_yaml_file(config_file) + loader = _get_kube_config_loader(filename=config_file) return loader.list_contexts(), loader.current_context @@ -734,8 +755,8 @@ def load_kube_config(config_file=None, context=None, if config_file is None: config_file = KUBE_CONFIG_DEFAULT_LOCATION - loader = _get_kube_config_loader_for_yaml_file( - config_file, active_context=context, + loader = _get_kube_config_loader( + filename=config_file, active_context=context, persist_config=persist_config) if client_configuration is None: @@ -745,6 +766,36 @@ def load_kube_config(config_file=None, context=None, else: loader.load_and_set(client_configuration) +def load_kube_config_from_dict(config_dict, context=None, + client_configuration=None, + persist_config=True): + """Loads authentication and cluster information from kube-config file + and stores them in kubernetes.client.configuration. + + :param config_dict: Takes the config file as a dict. + :param context: set the active context. If is set to None, current_context + from config file will be used. + :param client_configuration: The kubernetes.client.Configuration to + set configs to. + :param persist_config: If True, config file will be updated when changed + (e.g GCP token refresh). + """ + + if config_dict is None: + raise ConfigException( + 'Invalid kube-config dict. ' + 'No configuration found.') + + loader = _get_kube_config_loader( + config_dict=config_dict, active_context=context, + persist_config=persist_config) + + if client_configuration is None: + config = type.__call__(Configuration) + loader.load_and_set(config) + Configuration.set_default(config) + else: + loader.load_and_set(client_configuration) def new_client_from_config( config_file=None, From 27a1b811d4e2e4e68c049d090c3298cb9e545751 Mon Sep 17 00:00:00 2001 From: vishnu667 Date: Thu, 14 May 2020 16:13:47 +0530 Subject: [PATCH 054/141] Re-using the _get_kube_config_loader in _get_kube_config_loader_for_yaml_file --- config/kube_config.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index f82265c0..023ace74 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -688,28 +688,19 @@ def save_config(self, path): yaml.safe_dump(self.config_files[path], f, default_flow_style=False) - def _get_kube_config_loader_for_yaml_file( filename, persist_config=False, **kwargs): - - kcfg = KubeConfigMerger(filename) - if persist_config and 'config_persister' not in kwargs: - kwargs['config_persister'] = kcfg.save_changes - - if kcfg.config is None: - raise ConfigException( - 'Invalid kube-config file. ' - 'No configuration found.') - - return KubeConfigLoader( - config_dict=kcfg.config, - config_base_path=None, + return _get_kube_config_loader( + filename=filename, + persist_config=persist_config, **kwargs) def _get_kube_config_loader( - filename=None,config_dict=None, persist_config=False, **kwargs): - - if (config_dict is None): + filename=None, + config_dict=None, + persist_config=False, + **kwargs): + if config_dict is None: kcfg = KubeConfigMerger(filename) if persist_config and 'config_persister' not in kwargs: kwargs['config_persister'] = kcfg.save_changes From e92495f0d1e2a108dcdd562900a4f7fa5ee1f5fe Mon Sep 17 00:00:00 2001 From: vishnu667 Date: Fri, 15 May 2020 20:16:04 +0530 Subject: [PATCH 055/141] Adding test Cases --- config/kube_config_test.py | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index c8a4c93b..c8dce3b4 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -34,7 +34,9 @@ KubeConfigMerger, _cleanup_temp_files, _create_temp_file_with_content, _get_kube_config_loader_for_yaml_file, + _get_kube_config_loader, list_kube_config_contexts, load_kube_config, + load_kube_config_from_dict, new_client_from_config) BEARER_TOKEN_FORMAT = "Bearer %s" @@ -1229,6 +1231,16 @@ def test_load_kube_config(self): client_configuration=actual) self.assertEqual(expected, actual) + def test_load_kube_config_from_dict(self): + expected = FakeConfig(host=TEST_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) + + actual = FakeConfig() + load_kube_config_from_dict(config_dict=self.TEST_KUBE_CONFIG, + context="simple_token", + client_configuration=actual) + self.assertEqual(expected, actual) + def test_list_kube_config_contexts(self): config_file = self._create_temp_file( yaml.safe_dump(self.TEST_KUBE_CONFIG)) @@ -1344,6 +1356,39 @@ def test__get_kube_config_loader_for_yaml_file_persist(self): self.assertTrue(callable(actual._config_persister)) self.assertEquals(actual._config_persister.__name__, "save_changes") + def test__get_kube_config_loader_file_no_persist(self): + expected = FakeConfig(host=TEST_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) + config_file = self._create_temp_file( + yaml.safe_dump(self.TEST_KUBE_CONFIG)) + actual = _get_kube_config_loader(filename=config_file) + self.assertIsNone(actual._config_persister) + + def test__get_kube_config_loader_file_persist(self): + expected = FakeConfig(host=TEST_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) + config_file = self._create_temp_file( + yaml.safe_dump(self.TEST_KUBE_CONFIG)) + actual = _get_kube_config_loader(filename=config_file, + persist_config=True) + self.assertTrue(callable(actual._config_persister)) + self.assertEquals(actual._config_persister.__name__, "save_changes") + + def test__get_kube_config_loader_dict_no_persist(self): + expected = FakeConfig(host=TEST_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) + actual = _get_kube_config_loader_for_yaml_file(config_dict=self.TEST_KUBE_CONFIG) + self.assertIsNone(actual._config_persister) + + def test__get_kube_config_loader_dict_persist(self): + expected = FakeConfig(host=TEST_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) + actual = _get_kube_config_loader(config_dict=self.TEST_KUBE_CONFIG, + persist_config=True) + self.assertTrue(callable(actual._config_persister)) + self.assertEquals(actual._config_persister.__name__, "save_changes") + + class TestKubernetesClientConfiguration(BaseTestCase): # Verifies properties of kubernetes.client.Configuration. From c6e8194127009b19a95b1ba9b67820d748df9fbf Mon Sep 17 00:00:00 2001 From: vishnu667 Date: Fri, 15 May 2020 22:14:22 +0530 Subject: [PATCH 056/141] Adding config to init file and indentation fixes --- config/__init__.py | 2 +- config/kube_config_test.py | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 3476ff71..83bd581c 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -15,4 +15,4 @@ from .config_exception import ConfigException from .incluster_config import load_incluster_config from .kube_config import (list_kube_config_contexts, load_kube_config, - new_client_from_config) + new_client_from_config, load_kube_config_from_dict) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index c8dce3b4..bc855e1a 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -33,11 +33,10 @@ ConfigNode, FileOrData, KubeConfigLoader, KubeConfigMerger, _cleanup_temp_files, _create_temp_file_with_content, - _get_kube_config_loader_for_yaml_file, _get_kube_config_loader, + _get_kube_config_loader_for_yaml_file, list_kube_config_contexts, load_kube_config, - load_kube_config_from_dict, - new_client_from_config) + load_kube_config_from_dict, new_client_from_config) BEARER_TOKEN_FORMAT = "Bearer %s" @@ -1237,8 +1236,8 @@ def test_load_kube_config_from_dict(self): actual = FakeConfig() load_kube_config_from_dict(config_dict=self.TEST_KUBE_CONFIG, - context="simple_token", - client_configuration=actual) + context="simple_token", + client_configuration=actual) self.assertEqual(expected, actual) def test_list_kube_config_contexts(self): @@ -1370,26 +1369,26 @@ def test__get_kube_config_loader_file_persist(self): config_file = self._create_temp_file( yaml.safe_dump(self.TEST_KUBE_CONFIG)) actual = _get_kube_config_loader(filename=config_file, - persist_config=True) + persist_config=True) self.assertTrue(callable(actual._config_persister)) self.assertEquals(actual._config_persister.__name__, "save_changes") def test__get_kube_config_loader_dict_no_persist(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) - actual = _get_kube_config_loader_for_yaml_file(config_dict=self.TEST_KUBE_CONFIG) + actual = _get_kube_config_loader_for_yaml_file( + config_dict=self.TEST_KUBE_CONFIG) self.assertIsNone(actual._config_persister) def test__get_kube_config_loader_dict_persist(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) actual = _get_kube_config_loader(config_dict=self.TEST_KUBE_CONFIG, - persist_config=True) + persist_config=True) self.assertTrue(callable(actual._config_persister)) self.assertEquals(actual._config_persister.__name__, "save_changes") - class TestKubernetesClientConfiguration(BaseTestCase): # Verifies properties of kubernetes.client.Configuration. # These tests guard against changes to the upstream configuration class, From 6c327377e820dc70b02f379b6c5ec4dea22667e7 Mon Sep 17 00:00:00 2001 From: vishnu667 Date: Fri, 22 May 2020 00:05:15 +0530 Subject: [PATCH 057/141] test case fix __get_kube_config_loader_dict_no_persist --- config/kube_config_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index bc855e1a..d6501a58 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1376,7 +1376,7 @@ def test__get_kube_config_loader_file_persist(self): def test__get_kube_config_loader_dict_no_persist(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) - actual = _get_kube_config_loader_for_yaml_file( + actual = _get_kube_config_loader( config_dict=self.TEST_KUBE_CONFIG) self.assertIsNone(actual._config_persister) From 52a3bdc159b0a388a546dbbd85e8e0db5dcd05c5 Mon Sep 17 00:00:00 2001 From: vishnu667 Date: Fri, 22 May 2020 00:57:46 +0530 Subject: [PATCH 058/141] removing load from dict presist from the added test cases. --- config/kube_config_test.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index d6501a58..25508d8b 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1380,15 +1380,6 @@ def test__get_kube_config_loader_dict_no_persist(self): config_dict=self.TEST_KUBE_CONFIG) self.assertIsNone(actual._config_persister) - def test__get_kube_config_loader_dict_persist(self): - expected = FakeConfig(host=TEST_HOST, - token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) - actual = _get_kube_config_loader(config_dict=self.TEST_KUBE_CONFIG, - persist_config=True) - self.assertTrue(callable(actual._config_persister)) - self.assertEquals(actual._config_persister.__name__, "save_changes") - - class TestKubernetesClientConfiguration(BaseTestCase): # Verifies properties of kubernetes.client.Configuration. # These tests guard against changes to the upstream configuration class, From 3ff79da50cd4f02cb789eee12461ad70ba151303 Mon Sep 17 00:00:00 2001 From: ACXLM Date: Tue, 2 Jun 2020 13:55:43 +0800 Subject: [PATCH 059/141] fix cfg is none, load kube config error Signed-off-by: zhu hui --- config/kube_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 43676728..cb7a9bb3 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -663,9 +663,8 @@ def load_config(self, path): for item in ('clusters', 'contexts', 'users'): config_merged[item] = [] self.config_merged = ConfigNode(path, config_merged, path) - for item in ('clusters', 'contexts', 'users'): - self._merge(item, config.get(item, {}), path) + self._merge(item, config.get(item, []) or [], path) self.config_files[path] = config def _merge(self, item, add_cfg, path): From 91812350e4c2e8a965bd29c0e0c948d82a57936d Mon Sep 17 00:00:00 2001 From: vishnu667 Date: Sat, 20 Jun 2020 00:50:39 +0530 Subject: [PATCH 060/141] updated docstring for load_kube_config_from_dict --- config/kube_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 023ace74..423178b6 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -760,7 +760,7 @@ def load_kube_config(config_file=None, context=None, def load_kube_config_from_dict(config_dict, context=None, client_configuration=None, persist_config=True): - """Loads authentication and cluster information from kube-config file + """Loads authentication and cluster information from config_dict file and stores them in kubernetes.client.configuration. :param config_dict: Takes the config file as a dict. From 982de11392c481a248bb0090e223c7b176a29a1a Mon Sep 17 00:00:00 2001 From: Haowei Cai Date: Mon, 22 Jun 2020 15:43:21 -0700 Subject: [PATCH 061/141] generated by scripts/update-pycodestyle.sh in main repo --- config/__init__.py | 2 +- config/kube_config.py | 9 +++++++-- config/kube_config_test.py | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 83bd581c..b57bf185 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -15,4 +15,4 @@ from .config_exception import ConfigException from .incluster_config import load_incluster_config from .kube_config import (list_kube_config_contexts, load_kube_config, - new_client_from_config, load_kube_config_from_dict) + load_kube_config_from_dict, new_client_from_config) diff --git a/config/kube_config.py b/config/kube_config.py index 423178b6..892e9043 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -688,6 +688,7 @@ def save_config(self, path): yaml.safe_dump(self.config_files[path], f, default_flow_style=False) + def _get_kube_config_loader_for_yaml_file( filename, persist_config=False, **kwargs): return _get_kube_config_loader( @@ -695,6 +696,7 @@ def _get_kube_config_loader_for_yaml_file( persist_config=persist_config, **kwargs) + def _get_kube_config_loader( filename=None, config_dict=None, @@ -719,6 +721,7 @@ def _get_kube_config_loader( config_base_path=None, **kwargs) + def list_kube_config_contexts(config_file=None): if config_file is None: @@ -757,9 +760,10 @@ def load_kube_config(config_file=None, context=None, else: loader.load_and_set(client_configuration) + def load_kube_config_from_dict(config_dict, context=None, - client_configuration=None, - persist_config=True): + client_configuration=None, + persist_config=True): """Loads authentication and cluster information from config_dict file and stores them in kubernetes.client.configuration. @@ -788,6 +792,7 @@ def load_kube_config_from_dict(config_dict, context=None, else: loader.load_and_set(client_configuration) + def new_client_from_config( config_file=None, context=None, diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 25508d8b..3dca177c 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1380,6 +1380,7 @@ def test__get_kube_config_loader_dict_no_persist(self): config_dict=self.TEST_KUBE_CONFIG) self.assertIsNone(actual._config_persister) + class TestKubernetesClientConfiguration(BaseTestCase): # Verifies properties of kubernetes.client.Configuration. # These tests guard against changes to the upstream configuration class, From 30d9e2af1cb7d1416ba4e4f3ddc3c36653b7284f Mon Sep 17 00:00:00 2001 From: ACXLM Date: Tue, 9 Jun 2020 17:53:06 +0800 Subject: [PATCH 062/141] change test case --- config/kube_config_test.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index c8a4c93b..792d4bde 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1342,7 +1342,7 @@ def test__get_kube_config_loader_for_yaml_file_persist(self): actual = _get_kube_config_loader_for_yaml_file(config_file, persist_config=True) self.assertTrue(callable(actual._config_persister)) - self.assertEquals(actual._config_persister.__name__, "save_changes") + self.assertEqual(actual._config_persister.__name__, "save_changes") class TestKubernetesClientConfiguration(BaseTestCase): @@ -1517,6 +1517,26 @@ class TestKubeConfigMerger(BaseTestCase): } ] } + TEST_KUBE_CONFIG_PART6 = { + "current-context": "no_user", + "contexts": [ + { + "name": "no_user", + "context": { + "cluster": "default" + } + }, + ], + "clusters": [ + { + "name": "default", + "cluster": { + "server": TEST_HOST + } + }, + ], + "users": None + } def _create_multi_config(self): files = [] @@ -1525,7 +1545,8 @@ def _create_multi_config(self): self.TEST_KUBE_CONFIG_PART2, self.TEST_KUBE_CONFIG_PART3, self.TEST_KUBE_CONFIG_PART4, - self.TEST_KUBE_CONFIG_PART5): + self.TEST_KUBE_CONFIG_PART5, + self.TEST_KUBE_CONFIG_PART6): files.append(self._create_temp_file(yaml.safe_dump(part))) return ENV_KUBECONFIG_PATH_SEPARATOR.join(files) From a270ea294621687bad118a04508a8e820c0de8a8 Mon Sep 17 00:00:00 2001 From: Abhijeet Kasurde Date: Thu, 2 Jul 2020 23:04:48 +0530 Subject: [PATCH 063/141] FileOrData: Handle None object Return when object is None in FileOrData class Signed-off-by: Abhijeet Kasurde --- config/kube_config.py | 2 ++ config/kube_config_test.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/config/kube_config.py b/config/kube_config.py index 9786e0e5..ec185871 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -97,6 +97,8 @@ def __init__(self, obj, file_key_name, data_key_name=None, self._file = None self._data = None self._base64_file_content = base64_file_content + if not obj: + return if data_key_name in obj: self._data = obj[data_key_name] elif file_key_name in obj: diff --git a/config/kube_config_test.py b/config/kube_config_test.py index ef5616e4..0c3b42ae 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -255,6 +255,16 @@ def test_file_given_data_bytes_no_base64(self): data_key_name=TEST_DATA_KEY, base64_file_content=False) self.assertEqual(TEST_DATA, self.get_file_content(t.as_file())) + def test_file_given_no_object(self): + t = FileOrData(obj=None, file_key_name=TEST_FILE_KEY, + data_key_name=TEST_DATA_KEY) + self.assertEqual(t.as_file(), None) + + def test_file_given_no_object_data(self): + t = FileOrData(obj=None, file_key_name=TEST_FILE_KEY, + data_key_name=TEST_DATA_KEY) + self.assertEqual(t.as_data(), None) + class TestConfigNode(BaseTestCase): From 06e48c585c003742ff42fb1995ec18e85226055e Mon Sep 17 00:00:00 2001 From: Mitar Date: Mon, 11 Feb 2019 00:23:39 -0800 Subject: [PATCH 064/141] Retry watch if request expires. --- watch/watch.py | 30 ++++++++++++++++++++++++++++-- watch/watch_test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/watch/watch.py b/watch/watch.py index fe7a9247..f67dbe45 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import http import json import pydoc @@ -86,7 +87,7 @@ def get_watch_argument_name(self, func): def unmarshal_event(self, data, return_type): js = json.loads(data) js['raw_object'] = js['object'] - if return_type: + if return_type and js['type'] != 'ERROR': obj = SimpleNamespace(data=json.dumps(js['raw_object'])) js['object'] = self._api_client.deserialize(obj, return_type) if hasattr(js['object'], 'metadata'): @@ -102,6 +103,14 @@ def unmarshal_event(self, data, return_type): def stream(self, func, *args, **kwargs): """Watch an API resource and stream the result back via a generator. + Note that watching an API resource can expire. The method tries to + resume automatically once from the last result, but if that last result + is too old as well, an `ApiException` exception will be thrown with + ``code`` 410. In that case you have to recover yourself, probably + by listing the API resource to obtain the latest state and then + watching from that state on by setting ``resource_version`` to + one returned from listing. + :param func: The API function pointer. Any parameter to the function can be passed after this parameter. @@ -134,6 +143,7 @@ def stream(self, func, *args, **kwargs): self.resource_version = kwargs['resource_version'] timeouts = ('timeout_seconds' in kwargs) + retry_after_410 = False while True: resp = func(*args, **kwargs) try: @@ -141,7 +151,23 @@ def stream(self, func, *args, **kwargs): # unmarshal when we are receiving events from watch, # return raw string when we are streaming log if watch_arg == "watch": - yield self.unmarshal_event(line, return_type) + event = self.unmarshal_event(line, return_type) + if isinstance(event, dict) \ + and event['type'] == 'ERROR': + obj = event['raw_object'] + # Current request expired, let's retry, + # but only if we have not already retried. + if not retry_after_410 and \ + obj['code'] == http.HTTPStatus.GONE: + retry_after_410 = True + break + else: + reason = "%s: %s" % (obj['reason'], obj['message']) + raise client.rest.ApiException(status=obj['code'], + reason=reason) + else: + retry_after_410 = False + yield event else: yield line if self._stop: diff --git a/watch/watch_test.py b/watch/watch_test.py index 6fec23ec..b8cefd20 100644 --- a/watch/watch_test.py +++ b/watch/watch_test.py @@ -16,6 +16,8 @@ from mock import Mock, call +from kubernetes import client + from .watch import Watch @@ -273,6 +275,31 @@ def test_watch_with_exception(self): fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() + def test_watch_with_error_event(self): + fake_resp = Mock() + fake_resp.close = Mock() + fake_resp.release_conn = Mock() + fake_resp.read_chunked = Mock( + return_value=[ + '{"type": "ERROR", "object": {"code": 410, ' + '"reason": "Gone", "message": "error message"}}\n']) + + fake_api = Mock() + fake_api.get_thing = Mock(return_value=fake_resp) + + w = Watch() + try: + for _ in w.stream(fake_api.get_thing): + self.fail(self, "Should fail with ApiException.") + except client.rest.ApiException: + pass + + fake_api.get_thing.assert_called_once_with( + _preload_content=False, watch=True) + fake_resp.read_chunked.assert_called_once_with(decode_content=False) + fake_resp.close.assert_called_once() + fake_resp.release_conn.assert_called_once() + if __name__ == '__main__': unittest.main() From a7c78291bf249a32c8ef32c00e952d9c1dee9dbb Mon Sep 17 00:00:00 2001 From: Haowei Cai Date: Thu, 16 Jul 2020 00:38:24 -0700 Subject: [PATCH 065/141] add old api_key to set auth attributes --- config/kube_config_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index ef5616e4..1349cafe 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1399,11 +1399,13 @@ def test_get_api_key_with_prefix_returns_token(self): def test_auth_settings_calls_get_api_key_with_prefix(self): expected_token = 'expected_token' + old_token = 'old_token' def fake_get_api_key_with_prefix(identifier): self.assertEqual('authorization', identifier) return expected_token config = Configuration() + config.api_key['authorization'] = old_token config.get_api_key_with_prefix = fake_get_api_key_with_prefix self.assertEqual(expected_token, config.auth_settings()['BearerToken']['value']) From a54f404366c0800497f8b62122d7be77c143297f Mon Sep 17 00:00:00 2001 From: Nabarun Pal Date: Thu, 16 Jul 2020 14:02:12 +0530 Subject: [PATCH 066/141] Fix a Python 2 compatibility issue PR #133 introduces the usage of `http` module for checking the status code for `GONE` HTTP status. However, this doesn't work in Python 2.7. This commit checks if the interpreter is Python 2 and imports the status code from `httplib` module instead and unifies the approach to the checks. Signed-off-by: Nabarun Pal --- watch/watch.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/watch/watch.py b/watch/watch.py index f67dbe45..6410dfab 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import http import json import pydoc +import sys from kubernetes import client @@ -29,6 +29,15 @@ TYPE_LIST_SUFFIX = "List" +PY2 = sys.version_info[0] == 2 +if PY2: + import httplib + HTTP_STATUS_GONE = httplib.GONE +else: + import http + HTTP_STATUS_GONE = http.HTTPStatus.GONE + + class SimpleNamespace: def __init__(self, **kwargs): @@ -158,7 +167,7 @@ def stream(self, func, *args, **kwargs): # Current request expired, let's retry, # but only if we have not already retried. if not retry_after_410 and \ - obj['code'] == http.HTTPStatus.GONE: + obj['code'] == HTTP_STATUS_GONE: retry_after_410 = True break else: From b68ca3055178e31a5d87a0a98780e4987a4d23ae Mon Sep 17 00:00:00 2001 From: Nabarun Pal Date: Thu, 16 Jul 2020 14:08:44 +0530 Subject: [PATCH 067/141] Fixes codestyle failures Signed-off-by: Nabarun Pal --- watch/watch.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/watch/watch.py b/watch/watch.py index 6410dfab..3058ed9a 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -171,9 +171,10 @@ def stream(self, func, *args, **kwargs): retry_after_410 = True break else: - reason = "%s: %s" % (obj['reason'], obj['message']) - raise client.rest.ApiException(status=obj['code'], - reason=reason) + reason = "%s: %s" % ( + obj['reason'], obj['message']) + raise client.rest.ApiException( + status=obj['code'], reason=reason) else: retry_after_410 = False yield event From b85aff2b3e6c950cb9128d281cd6f7394563e202 Mon Sep 17 00:00:00 2001 From: Graham Reed Date: Fri, 29 May 2020 17:09:38 +0100 Subject: [PATCH 068/141] Accept client certificates from an authn/authz plugin (Plugin interface reference: https://kubernetes.io/docs/reference/access-authn-authz/authentication/#input-and-output-formats) When handling the response from the authn/authz plugin, `token` will be used if provided, which maintains current behaviour. Newly added is handling `clientCertificateData`: if it is present, that certificate (and its key) will be used as provided by the plugin. (And any certificate/key pair provided via the `users` section of the configuration file will be ignored.) --- config/kube_config.py | 46 +++++++++++++++++++++++++++++--------- config/kube_config_test.py | 35 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 9786e0e5..c3ba04ca 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -472,11 +472,31 @@ def _load_from_exec_plugin(self): return try: status = ExecProvider(self._user['exec']).run() - if 'token' not in status: - logging.error('exec: missing token field in plugin output') - return None - self.token = "Bearer %s" % status['token'] - return True + if 'token' in status: + self.token = "Bearer %s" % status['token'] + return True + if 'clientCertificateData' in status: + # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#input-and-output-formats + # Plugin has provided certificates instead of a token. + if 'clientKeyData' not in status: + logging.error('exec: missing clientKeyData field in ' + 'plugin output') + return None + base_path = self._get_base_path(self._cluster.path) + self.cert_file = FileOrData( + status, None, + data_key_name='clientCertificateData', + file_base_path=base_path, + base64_file_content=False).as_file() + self.key_file = FileOrData( + status, None, + data_key_name='clientKeyData', + file_base_path=base_path, + base64_file_content=False).as_file() + return True + logging.error('exec: missing token or clientCertificateData field ' + 'in plugin output') + return None except Exception as e: logging.error(str(e)) @@ -512,12 +532,16 @@ def _load_cluster_info(self): self.ssl_ca_cert = FileOrData( self._cluster, 'certificate-authority', file_base_path=base_path).as_file() - self.cert_file = FileOrData( - self._user, 'client-certificate', - file_base_path=base_path).as_file() - self.key_file = FileOrData( - self._user, 'client-key', - file_base_path=base_path).as_file() + if 'cert_file' not in self.__dict__: + # cert_file could have been provided by + # _load_from_exec_plugin; only load from the _user + # section if we need it. + self.cert_file = FileOrData( + self._user, 'client-certificate', + file_base_path=base_path).as_file() + self.key_file = FileOrData( + self._user, 'client-key', + file_base_path=base_path).as_file() if 'insecure-skip-tls-verify' in self._cluster: self.verify_ssl = not self._cluster['insecure-skip-tls-verify'] diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 1349cafe..63cf11aa 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -541,6 +541,13 @@ class TestKubeConfigLoader(BaseTestCase): "user": "exec_cred_user" } }, + { + "name": "exec_cred_user_certificate", + "context": { + "cluster": "ssl", + "user": "exec_cred_user_certificate" + } + }, { "name": "contexttestcmdpath", "context": { @@ -865,6 +872,16 @@ class TestKubeConfigLoader(BaseTestCase): } } }, + { + "name": "exec_cred_user_certificate", + "user": { + "exec": { + "apiVersion": "client.authentication.k8s.io/v1beta1", + "command": "custom-certificate-authenticator", + "args": [] + } + } + }, { "name": "usertestcmdpath", "user": { @@ -1295,6 +1312,24 @@ def test_user_exec_auth(self, mock): active_context="exec_cred_user").load_and_set(actual) self.assertEqual(expected, actual) + @mock.patch('kubernetes.config.kube_config.ExecProvider.run') + def test_user_exec_auth_certificates(self, mock): + mock.return_value = { + "clientCertificateData": TEST_CLIENT_CERT, + "clientKeyData": TEST_CLIENT_KEY, + } + expected = FakeConfig( + host=TEST_SSL_HOST, + cert_file=self._create_temp_file(TEST_CLIENT_CERT), + key_file=self._create_temp_file(TEST_CLIENT_KEY), + ssl_ca_cert=self._create_temp_file(TEST_CERTIFICATE_AUTH), + verify_ssl=True) + actual = FakeConfig() + KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="exec_cred_user_certificate").load_and_set(actual) + self.assertEqual(expected, actual) + def test_user_cmd_path(self): A = namedtuple('A', ['token', 'expiry']) token = "dummy" From f65f06b1ed4388a1ab030215deb4381ec438f318 Mon Sep 17 00:00:00 2001 From: Dylan Shepard Date: Wed, 19 Aug 2020 12:36:32 -0700 Subject: [PATCH 069/141] commiting changes to branch --- config/kube_config.py | 29 +++++++++++++++++++++-------- config/kube_config_test.py | 15 +++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 68910841..a1fc59c9 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -19,6 +19,7 @@ import json import logging import os +import io import platform import subprocess import tempfile @@ -667,19 +668,31 @@ def __init__(self, paths): self.paths = [] self.config_files = {} self.config_merged = None - - for path in paths.split(ENV_KUBECONFIG_PATH_SEPARATOR): - if path: - path = os.path.expanduser(path) - if os.path.exists(path): - self.paths.append(path) - self.load_config(path) - self.config_saved = copy.deepcopy(self.config_files) + if hasattr(paths, 'read'): + self.load_config_from_fileish(paths) + else: + for path in paths.split(ENV_KUBECONFIG_PATH_SEPARATOR): + if path: + path = os.path.expanduser(path) + if os.path.exists(path): + self.paths.append(path) + self.load_config(path) + self.config_saved = copy.deepcopy(self.config_files) @property def config(self): return self.config_merged + def load_config_from_fileish(self, string): + if hasattr(string, 'getvalue'): + config = yaml.safe_load(string.getvalue()) + else: + config = yaml.safe_load(string.read()) + + if self.config_merged is None: + self.config_merged = copy.deepcopy(config) + # doesn't need to do any further merging + def load_config(self, path): with open(path) as f: config = yaml.safe_load(f) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 4b406b34..a666cff2 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -16,6 +16,7 @@ import datetime import json import os +import io import shutil import tempfile import unittest @@ -1257,6 +1258,14 @@ def test_load_kube_config(self): client_configuration=actual) self.assertEqual(expected, actual) + def test_load_kube_config_from_stringio(self): + expected = FakeConfig(host=TEST_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) + kubeconfig = self._create_stringio_config() + actual = FakeConfig() + load_kube_config(config_file=kubeconfig, context="simple_token", client_configuration=actual) + self.assertEqual(expected, actual) + def test_load_kube_config_from_dict(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) @@ -1633,6 +1642,11 @@ def _create_multi_config(self): files.append(self._create_temp_file(yaml.safe_dump(part))) return ENV_KUBECONFIG_PATH_SEPARATOR.join(files) + def _create_stringio_config(self): + obj = io.StringIO() + obj.write(self.TEST_KUBE_CONFIG_PART1) + return obj + def test_list_kube_config_contexts(self): kubeconfigs = self._create_multi_config() expected_contexts = [ @@ -1660,6 +1674,7 @@ def test_new_client_from_config(self): self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, client.configuration.api_key['authorization']) + def test_save_changes(self): kubeconfigs = self._create_multi_config() From aac4e35ca9f14aaaa741f200283f3cfe0a85f1d9 Mon Sep 17 00:00:00 2001 From: Dylan Shepard Date: Wed, 19 Aug 2020 12:49:33 -0700 Subject: [PATCH 070/141] correcting tests --- config/kube_config_test.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index a666cff2..e53c5767 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1258,12 +1258,13 @@ def test_load_kube_config(self): client_configuration=actual) self.assertEqual(expected, actual) - def test_load_kube_config_from_stringio(self): + def test_load_kube_config_from_fileish(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) - kubeconfig = self._create_stringio_config() + config_fileish = io.StringIO() + config_fileish.write(yaml.safe_dump(self.TEST_KUBE_CONFIG)) actual = FakeConfig() - load_kube_config(config_file=kubeconfig, context="simple_token", client_configuration=actual) + load_kube_config(config_file=config_fileish, context="simple_token", client_configuration=actual) self.assertEqual(expected, actual) def test_load_kube_config_from_dict(self): @@ -1642,11 +1643,6 @@ def _create_multi_config(self): files.append(self._create_temp_file(yaml.safe_dump(part))) return ENV_KUBECONFIG_PATH_SEPARATOR.join(files) - def _create_stringio_config(self): - obj = io.StringIO() - obj.write(self.TEST_KUBE_CONFIG_PART1) - return obj - def test_list_kube_config_contexts(self): kubeconfigs = self._create_multi_config() expected_contexts = [ From fd62214e288076c8fde7dfeed1c5576c62002044 Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Wed, 26 Aug 2020 18:18:00 -1000 Subject: [PATCH 071/141] Refactor stream package to enable common method helpers for other streaming api classes. --- stream/stream.py | 19 +++---- stream/ws_client.py | 117 ++++++++++++++++++++++++-------------------- 2 files changed, 70 insertions(+), 66 deletions(-) diff --git a/stream/stream.py b/stream/stream.py index 6d5f05f8..627fd1a3 100644 --- a/stream/stream.py +++ b/stream/stream.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import types + from . import ws_client @@ -19,19 +21,10 @@ def stream(func, *args, **kwargs): """Stream given API call using websocket. Extra kwarg: capture-all=True - captures all stdout+stderr for use with WSClient.read_all()""" - def _intercept_request_call(*args, **kwargs): - # old generated code's api client has config. new ones has - # configuration - try: - config = func.__self__.api_client.configuration - except AttributeError: - config = func.__self__.api_client.config - - return ws_client.websocket_call(config, *args, **kwargs) - - prev_request = func.__self__.api_client.request + api_client = func.__self__.api_client + prev_request = api_client.request try: - func.__self__.api_client.request = _intercept_request_call + api_client.request = types.MethodType(ws_client.websocket_call, api_client) return func(*args, **kwargs) finally: - func.__self__.api_client.request = prev_request + api_client.request = prev_request diff --git a/stream/ws_client.py b/stream/ws_client.py index 2b599381..31300363 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -23,7 +23,7 @@ import six import yaml -from six.moves.urllib.parse import urlencode, quote_plus, urlparse, urlunparse +from six.moves.urllib.parse import urlencode, urlparse, urlunparse from six import StringIO from websocket import WebSocket, ABNF, enableTrace @@ -51,47 +51,13 @@ def __init__(self, configuration, url, headers, capture_all): like port forwarding can forward different pods' streams to different channels. """ - enableTrace(False) - header = [] self._connected = False self._channels = {} if capture_all: self._all = StringIO() else: self._all = _IgnoredIO() - - # We just need to pass the Authorization, ignore all the other - # http headers we get from the generated code - if headers and 'authorization' in headers: - header.append("authorization: %s" % headers['authorization']) - - if headers and 'sec-websocket-protocol' in headers: - header.append("sec-websocket-protocol: %s" % - headers['sec-websocket-protocol']) - else: - header.append("sec-websocket-protocol: v4.channel.k8s.io") - - if url.startswith('wss://') and configuration.verify_ssl: - ssl_opts = { - 'cert_reqs': ssl.CERT_REQUIRED, - 'ca_certs': configuration.ssl_ca_cert or certifi.where(), - } - if configuration.assert_hostname is not None: - ssl_opts['check_hostname'] = configuration.assert_hostname - else: - ssl_opts = {'cert_reqs': ssl.CERT_NONE} - - if configuration.cert_file: - ssl_opts['certfile'] = configuration.cert_file - if configuration.key_file: - ssl_opts['keyfile'] = configuration.key_file - - self.sock = WebSocket(sslopt=ssl_opts, skip_utf8_validation=False) - if configuration.proxy: - proxy_url = urlparse(configuration.proxy) - self.sock.connect(url, header=header, http_proxy_host=proxy_url.hostname, http_proxy_port=proxy_url.port) - else: - self.sock.connect(url, header=header) + self.sock = create_websocket(configuration, url, headers) self._connected = True def peek_channel(self, channel, timeout=0): @@ -259,41 +225,86 @@ def close(self, **kwargs): WSResponse = collections.namedtuple('WSResponse', ['data']) -def get_websocket_http://github.com/url(url): +def get_websocket_url(http://github.com/url,%20query_params=None): parsed_url = urlparse(url) parts = list(parsed_url) if parsed_url.scheme == 'http': parts[0] = 'ws' elif parsed_url.scheme == 'https': parts[0] = 'wss' + if query_params: + query = [] + for key, value in query_params: + if key == 'command' and isinstance(value, list): + for command in value: + query.append((key, command)) + else: + query.append((key, value)) + if query: + parts[4] = urlencode(query) return urlunparse(parts) -def websocket_call(configuration, *args, **kwargs): +def create_websocket(configuration, url, headers=None): + enableTrace(False) + + # We just need to pass the Authorization, ignore all the other + # http headers we get from the generated code + header = [] + if headers and 'authorization' in headers: + header.append("authorization: %s" % headers['authorization']) + if headers and 'sec-websocket-protocol' in headers: + header.append("sec-websocket-protocol: %s" % + headers['sec-websocket-protocol']) + else: + header.append("sec-websocket-protocol: v4.channel.k8s.io") + + if url.startswith('wss://') and configuration.verify_ssl: + ssl_opts = { + 'cert_reqs': ssl.CERT_REQUIRED, + 'ca_certs': configuration.ssl_ca_cert or certifi.where(), + } + if configuration.assert_hostname is not None: + ssl_opts['check_hostname'] = configuration.assert_hostname + else: + ssl_opts = {'cert_reqs': ssl.CERT_NONE} + + if configuration.cert_file: + ssl_opts['certfile'] = configuration.cert_file + if configuration.key_file: + ssl_opts['keyfile'] = configuration.key_file + + websocket = WebSocket(sslopt=ssl_opts, skip_utf8_validation=False) + if configuration.proxy: + proxy_url = urlparse(configuration.proxy) + websocket.connect(url, header=header, http_proxy_host=proxy_url.hostname, http_proxy_port=proxy_url.port) + else: + websocket.connect(url, header=header) + return websocket + + +def _configuration(api_client): + # old generated code's api client has config. new ones has + # configuration + try: + return api_client.configuration + except AttributeError: + return api_client.config + + +def websocket_call(api_client, _method, url, **kwargs): """An internal function to be called in api-client when a websocket connection is required. args and kwargs are the parameters of apiClient.request method.""" - url = args[1] + url = get_websocket_url(http://github.com/url,%20kwargs.get(%22query_params")) + headers = kwargs.get("headers") _request_timeout = kwargs.get("_request_timeout", 60) _preload_content = kwargs.get("_preload_content", True) capture_all = kwargs.get("capture_all", True) - headers = kwargs.get("headers") - - # Expand command parameter list to indivitual command params - query_params = [] - for key, value in kwargs.get("query_params", {}): - if key == 'command' and isinstance(value, list): - for command in value: - query_params.append((key, command)) - else: - query_params.append((key, value)) - - if query_params: - url += '?' + urlencode(query_params) try: - client = WSClient(configuration, get_websocket_http://github.com/url(url), headers, capture_all) + client = WSClient(_configuration(api_client), url, headers, capture_all) if not _preload_content: return client client.run_forever(timeout=_request_timeout) From a00ed7f87a8aea045fba35a5a89aec799e6180b9 Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Thu, 27 Aug 2020 16:07:05 -1000 Subject: [PATCH 072/141] Put extracting the "configuration" back into the stream.py module, and use functools.partial to orchestrate calling the websocket request hanlder. --- stream/stream.py | 23 +++++++++++++++-------- stream/ws_client.py | 15 +++------------ 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/stream/stream.py b/stream/stream.py index 627fd1a3..9bb59017 100644 --- a/stream/stream.py +++ b/stream/stream.py @@ -12,19 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -import types +import functools from . import ws_client -def stream(func, *args, **kwargs): - """Stream given API call using websocket. - Extra kwarg: capture-all=True - captures all stdout+stderr for use with WSClient.read_all()""" - - api_client = func.__self__.api_client +def _websocket_reqeust(websocket_request, api_method, *args, **kwargs): + """Override the ApiClient.request method with an alternative websocket based + method and call the supplied Kubernetes API method with that in place.""" + api_client = api_method.__self__.api_client + # old generated code's api client has config. new ones has configuration + try: + configuration = api_client.configuration + except AttributeError: + configuration = api_client.config prev_request = api_client.request try: - api_client.request = types.MethodType(ws_client.websocket_call, api_client) - return func(*args, **kwargs) + api_client.request = functools.partial(websocket_request, configuration) + return api_method(*args, **kwargs) finally: api_client.request = prev_request + + +stream = functools.partial(_websocket_reqeust, ws_client.websocket_call) diff --git a/stream/ws_client.py b/stream/ws_client.py index 31300363..fa7f393e 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -283,18 +283,9 @@ def create_websocket(configuration, url, headers=None): return websocket -def _configuration(api_client): - # old generated code's api client has config. new ones has - # configuration - try: - return api_client.configuration - except AttributeError: - return api_client.config - - -def websocket_call(api_client, _method, url, **kwargs): +def websocket_call(configuration, _method, url, **kwargs): """An internal function to be called in api-client when a websocket - connection is required. args and kwargs are the parameters of + connection is required. method, url, and kwargs are the parameters of apiClient.request method.""" url = get_websocket_url(http://github.com/url,%20kwargs.get(%22query_params")) @@ -304,7 +295,7 @@ def websocket_call(api_client, _method, url, **kwargs): capture_all = kwargs.get("capture_all", True) try: - client = WSClient(_configuration(api_client), url, headers, capture_all) + client = WSClient(configuration, url, headers, capture_all) if not _preload_content: return client client.run_forever(timeout=_request_timeout) From 74d0e292b8d637f168c51c6f655813af023df758 Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Sun, 23 Aug 2020 13:34:41 -1000 Subject: [PATCH 073/141] Implement port forwarding. --- stream/__init__.py | 2 +- stream/stream.py | 8 ++- stream/ws_client.py | 172 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/stream/__init__.py b/stream/__init__.py index e72d0583..cd346528 100644 --- a/stream/__init__.py +++ b/stream/__init__.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .stream import stream +from .stream import stream, portforward diff --git a/stream/stream.py b/stream/stream.py index 9bb59017..57bac758 100644 --- a/stream/stream.py +++ b/stream/stream.py @@ -17,9 +17,12 @@ from . import ws_client -def _websocket_reqeust(websocket_request, api_method, *args, **kwargs): +def _websocket_reqeust(websocket_request, force_kwargs, api_method, *args, **kwargs): """Override the ApiClient.request method with an alternative websocket based method and call the supplied Kubernetes API method with that in place.""" + if force_kwargs: + for kwarg, value in force_kwargs.items(): + kwargs[kwarg] = value api_client = api_method.__self__.api_client # old generated code's api client has config. new ones has configuration try: @@ -34,4 +37,5 @@ def _websocket_reqeust(websocket_request, api_method, *args, **kwargs): api_client.request = prev_request -stream = functools.partial(_websocket_reqeust, ws_client.websocket_call) +stream = functools.partial(_websocket_reqeust, ws_client.websocket_call, None) +portforward = functools.partial(_websocket_reqeust, ws_client.portforward_call, {'_preload_content':False}) diff --git a/stream/ws_client.py b/stream/ws_client.py index fa7f393e..69274d55 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -12,12 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from kubernetes.client.rest import ApiException +from kubernetes.client.rest import ApiException, ApiValueError import certifi import collections import select +import socket import ssl +import threading import time import six @@ -225,6 +227,143 @@ def close(self, **kwargs): WSResponse = collections.namedtuple('WSResponse', ['data']) +class PortForward: + def __init__(self, websocket, ports): + """A websocket client with support for port forwarding. + + Port Forward command sends on 2 channels per port, a read/write + data channel and a read only error channel. Both channels are sent an + initial frame contaning the port number that channel is associated with. + """ + + self.websocket = websocket + self.ports = {} + for ix, port_number in enumerate(ports): + self.ports[port_number] = self._Port(ix, port_number) + threading.Thread( + name="Kubernetes port forward proxy", target=self._proxy, daemon=True + ).start() + + def socket(self, port_number): + if port_number not in self.ports: + raise ValueError("Invalid port number") + return self.ports[port_number].socket + + def error(self, port_number): + if port_number not in self.ports: + raise ValueError("Invalid port number") + return self.ports[port_number].error + + def close(self): + for port in self.ports.values(): + port.socket.close() + + class _Port: + def __init__(self, ix, number): + self.number = number + self.channel = bytes([ix * 2]) + s, self.python = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + self.socket = self._Socket(s) + self.data = b'' + self.error = None + + class _Socket: + def __init__(self, socket): + self._socket = socket + + def __getattr__(self, name): + return getattr(self._socket, name) + + def setsockopt(self, level, optname, value): + # The following socket option is not valid with a socket created from socketpair, + # and is set when creating an SSLSocket from this socket. + if level == socket.IPPROTO_TCP and optname == socket.TCP_NODELAY: + return + self._socket.setsockopt(level, optname, value) + + # Proxy all socket data between the python code and the kubernetes websocket. + def _proxy(self): + channel_ports = [] + channel_initialized = [] + python_ports = {} + rlist = [] + for port in self.ports.values(): + channel_ports.append(port) + channel_initialized.append(False) + channel_ports.append(port) + channel_initialized.append(False) + python_ports[port.python] = port + rlist.append(port.python) + rlist.append(self.websocket.sock) + kubernetes_data = b'' + while True: + wlist = [] + for port in self.ports.values(): + if port.data: + wlist.append(port.python) + if kubernetes_data: + wlist.append(self.websocket.sock) + r, w, _ = select.select(rlist, wlist, []) + for s in w: + if s == self.websocket.sock: + sent = self.websocket.sock.send(kubernetes_data) + kubernetes_data = kubernetes_data[sent:] + else: + port = python_ports[s] + sent = port.python.send(port.data) + port.data = port.data[sent:] + for s in r: + if s == self.websocket.sock: + opcode, frame = self.websocket.recv_data_frame(True) + if opcode == ABNF.OPCODE_CLOSE: + for port in self.ports.values(): + port.python.close() + return + if opcode == ABNF.OPCODE_BINARY: + if not frame.data: + raise RuntimeError("Unexpected frame data size") + channel = frame.data[0] + if channel >= len(channel_ports): + raise RuntimeError("Unexpected channel number: " + str(channel)) + port = channel_ports[channel] + if channel_initialized[channel]: + if channel % 2: + port.error = frame.data[1:].decode() + if port.python in rlist: + port.python.close() + rlist.remove(port.python) + port.data = b'' + else: + port.data += frame.data[1:] + else: + if len(frame.data) != 3: + raise RuntimeError( + "Unexpected initial channel frame data size" + ) + port_number = frame.data[1] + (frame.data[2] * 256) + if port_number != port.number: + raise RuntimeError( + "Unexpected port number in initial channel frame: " + str(port_number) + ) + channel_initialized[channel] = True + elif opcode not in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG): + raise RuntimeError("Unexpected websocket opcode: " + str(opcode)) + else: + port = python_ports[s] + data = port.python.recv(1024 * 1024) + if data: + kubernetes_data += ABNF.create_frame( + port.channel + data, + ABNF.OPCODE_BINARY, + ).format() + else: + port.python.close() + rlist.remove(s) + if len(rlist) == 1: + self.websocket.close() + return + + def get_websocket_url(http://github.com/url,%20query_params=None): parsed_url = urlparse(url) parts = list(parsed_url) @@ -302,3 +441,34 @@ def websocket_call(configuration, _method, url, **kwargs): return WSResponse('%s' % ''.join(client.read_all())) except (Exception, KeyboardInterrupt, SystemExit) as e: raise ApiException(status=0, reason=str(e)) + + +def portforward_call(configuration, _method, url, **kwargs): + """An internal function to be called in api-client when a websocket + connection is required for port forwarding. args and kwargs are the + parameters of apiClient.request method.""" + + query_params = kwargs.get("query_params") + + ports = [] + for key, value in query_params: + if key == 'ports': + for port in value.split(','): + try: + port = int(port) + if not (0 < port < 65536): + raise ValueError + ports.append(port) + except ValueError: + raise ApiValueError("Invalid port number `" + str(port) + "`") + if not ports: + raise ApiValueError("Missing required parameter `ports`") + + url = get_websocket_url(http://github.com/url,%20query_params) + headers = kwargs.get("headers") + + try: + websocket = create_websocket(configuration, url, headers) + return PortForward(websocket, ports) + except (Exception, KeyboardInterrupt, SystemExit) as e: + raise ApiException(status=0, reason=str(e)) From f85a41fa31d47c7a5b153bdc2ca4fb0b1c60a710 Mon Sep 17 00:00:00 2001 From: Dylan Shepard Date: Mon, 31 Aug 2020 12:01:11 -0700 Subject: [PATCH 074/141] renaming functions and setting to internal --- config/kube_config.py | 21 ++++++++++++--------- config/kube_config_test.py | 12 ++++++------ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index a1fc59c9..14fd71a6 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -669,21 +669,15 @@ def __init__(self, paths): self.config_files = {} self.config_merged = None if hasattr(paths, 'read'): - self.load_config_from_fileish(paths) + self._load_config_from_file_like_object(paths) else: - for path in paths.split(ENV_KUBECONFIG_PATH_SEPARATOR): - if path: - path = os.path.expanduser(path) - if os.path.exists(path): - self.paths.append(path) - self.load_config(path) - self.config_saved = copy.deepcopy(self.config_files) + self._load_config_from_file_path(paths) @property def config(self): return self.config_merged - def load_config_from_fileish(self, string): + def _load_config_from_file_like_object(self, string): if hasattr(string, 'getvalue'): config = yaml.safe_load(string.getvalue()) else: @@ -693,6 +687,15 @@ def load_config_from_fileish(self, string): self.config_merged = copy.deepcopy(config) # doesn't need to do any further merging + def _load_config_from_file_path(self, string): + for path in string.split(ENV_KUBECONFIG_PATH_SEPARATOR): + if path: + path = os.path.expanduser(path) + if os.path.exists(path): + self.paths.append(path) + self.load_config(path) + self.config_saved = copy.deepcopy(self.config_files) + def load_config(self, path): with open(path) as f: config = yaml.safe_load(f) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index e53c5767..1f74d345 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1248,7 +1248,7 @@ def test_ssl_with_relative_ssl_files(self): finally: shutil.rmtree(temp_dir) - def test_load_kube_config(self): + def test_load_kube_config_from_file_path(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) config_file = self._create_temp_file( @@ -1258,19 +1258,19 @@ def test_load_kube_config(self): client_configuration=actual) self.assertEqual(expected, actual) - def test_load_kube_config_from_fileish(self): + def test_load_kube_config_from_file_like_object(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) - config_fileish = io.StringIO() - config_fileish.write(yaml.safe_dump(self.TEST_KUBE_CONFIG)) + config_file_like_object = io.StringIO() + config_file_like_object.write(yaml.safe_dump(self.TEST_KUBE_CONFIG)) actual = FakeConfig() - load_kube_config(config_file=config_fileish, context="simple_token", client_configuration=actual) + load_kube_config(config_file=config_file_like_object, context="simple_token", + client_configuration=actual) self.assertEqual(expected, actual) def test_load_kube_config_from_dict(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) - actual = FakeConfig() load_kube_config_from_dict(config_dict=self.TEST_KUBE_CONFIG, context="simple_token", From cc9ae10549db26dd1391de55f0da2f4946de4ad7 Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Mon, 31 Aug 2020 15:53:59 -1000 Subject: [PATCH 075/141] Address the following PR issues: * Rename `_Port.error` to be `_Port.error_channel`. * Correct comment about where setsockopt is being called. * Add comments clarifying why the double call to the same methods to setup channel information. * Allow for ports specified with both local and remote port numbers. --- stream/ws_client.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 69274d55..5decad80 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -249,7 +249,7 @@ def socket(self, port_number): raise ValueError("Invalid port number") return self.ports[port_number].socket - def error(self, port_number): + def error_channel(self, port_number): if port_number not in self.ports: raise ValueError("Invalid port number") return self.ports[port_number].error @@ -276,7 +276,7 @@ def __getattr__(self, name): def setsockopt(self, level, optname, value): # The following socket option is not valid with a socket created from socketpair, - # and is set when creating an SSLSocket from this socket. + # and is set by the http.client.HTTPConnection.connect method. if level == socket.IPPROTO_TCP and optname == socket.TCP_NODELAY: return self._socket.setsockopt(level, optname, value) @@ -288,8 +288,10 @@ def _proxy(self): python_ports = {} rlist = [] for port in self.ports.values(): + # Setup the data channel for this port number channel_ports.append(port) channel_initialized.append(False) + # Setup the error channel for this port number channel_ports.append(port) channel_initialized.append(False) python_ports[port.python] = port @@ -455,7 +457,8 @@ def portforward_call(configuration, _method, url, **kwargs): if key == 'ports': for port in value.split(','): try: - port = int(port) + # The last specified port is the remote port + port = int(port.split(':')[-1]) if not (0 < port < 65536): raise ValueError ports.append(port) From 72e372599d68c4e268512c4085ac9e2e13368ae2 Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Tue, 1 Sep 2020 18:33:33 -1000 Subject: [PATCH 076/141] Rework the parsing of the requested ports to support both a local port and a remote port. --- stream/ws_client.py | 77 ++++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 5decad80..971ab6b4 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -237,30 +237,30 @@ def __init__(self, websocket, ports): """ self.websocket = websocket - self.ports = {} - for ix, port_number in enumerate(ports): - self.ports[port_number] = self._Port(ix, port_number) + self.local_ports = {} + for ix, local_remote in enumerate(ports): + self.local_ports[local_remote[0]] = self._Port(ix, local_remote[1]) threading.Thread( name="Kubernetes port forward proxy", target=self._proxy, daemon=True ).start() - def socket(self, port_number): - if port_number not in self.ports: + def socket(self, local_number): + if local_number not in self.local_ports: raise ValueError("Invalid port number") - return self.ports[port_number].socket + return self.local_ports[local_number].socket - def error_channel(self, port_number): - if port_number not in self.ports: + def error(self, local_number): + if local_number not in self.local_ports: raise ValueError("Invalid port number") - return self.ports[port_number].error + return self.local_ports[local_number].error def close(self): - for port in self.ports.values(): + for port in self.local_ports.values(): port.socket.close() class _Port: - def __init__(self, ix, number): - self.number = number + def __init__(self, ix, remote_number): + self.remote_number = remote_number self.channel = bytes([ix * 2]) s, self.python = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) self.socket = self._Socket(s) @@ -287,7 +287,7 @@ def _proxy(self): channel_initialized = [] python_ports = {} rlist = [] - for port in self.ports.values(): + for port in self.local_ports.values(): # Setup the data channel for this port number channel_ports.append(port) channel_initialized.append(False) @@ -300,7 +300,7 @@ def _proxy(self): kubernetes_data = b'' while True: wlist = [] - for port in self.ports.values(): + for port in self.local_ports.values(): if port.data: wlist.append(port.python) if kubernetes_data: @@ -318,7 +318,7 @@ def _proxy(self): if s == self.websocket.sock: opcode, frame = self.websocket.recv_data_frame(True) if opcode == ABNF.OPCODE_CLOSE: - for port in self.ports.values(): + for port in self.local_ports.values(): port.python.close() return if opcode == ABNF.OPCODE_BINARY: @@ -330,11 +330,9 @@ def _proxy(self): port = channel_ports[channel] if channel_initialized[channel]: if channel % 2: - port.error = frame.data[1:].decode() - if port.python in rlist: - port.python.close() - rlist.remove(port.python) - port.data = b'' + if port.error is None: + port.error = '' + port.error += frame.data[1:].decode() else: port.data += frame.data[1:] else: @@ -343,7 +341,7 @@ def _proxy(self): "Unexpected initial channel frame data size" ) port_number = frame.data[1] + (frame.data[2] * 256) - if port_number != port.number: + if port_number != port.remote_number: raise RuntimeError( "Unexpected port number in initial channel frame: " + str(port_number) ) @@ -453,17 +451,38 @@ def portforward_call(configuration, _method, url, **kwargs): query_params = kwargs.get("query_params") ports = [] - for key, value in query_params: - if key == 'ports': - for port in value.split(','): + for ix in range(len(query_params)): + if query_params[ix][0] == 'ports': + remote_ports = [] + for port in query_params[ix][1].split(','): try: - # The last specified port is the remote port - port = int(port.split(':')[-1]) - if not (0 < port < 65536): + local_remote = port.split(':') + if len(local_remote) > 2: raise ValueError - ports.append(port) + if len(local_remote) == 1: + local_remote[0] = int(local_remote[0]) + if not (0 < local_remote[0] < 65536): + raise ValueError + local_remote.append(local_remote[0]) + elif len(local_remote) == 2: + if local_remote[0]: + local_remote[0] = int(local_remote[0]) + if not (0 <= local_remote[0] < 65536): + raise ValueError + else: + local_remote[0] = 0 + local_remote[1] = int(local_remote[1]) + if not (0 < local_remote[1] < 65536): + raise ValueError + if not local_remote[0]: + local_remote[0] = len(ports) + 1 + else: + raise ValueError + ports.append(local_remote) + remote_ports.append(str(local_remote[1])) except ValueError: - raise ApiValueError("Invalid port number `" + str(port) + "`") + raise ApiValueError("Invalid port number `" + port + "`") + query_params[ix] = ('ports', ','.join(remote_ports)) if not ports: raise ApiValueError("Missing required parameter `ports`") From 7bf04b384b8cfcdba6387cf61e1cd9d6052669ee Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Sun, 6 Sep 2020 09:25:58 -1000 Subject: [PATCH 077/141] Rework how the PortForward._proxy thread determines when and how to terminate. --- stream/ws_client.py | 151 +++++++++++++++++++++++--------------------- 1 file changed, 78 insertions(+), 73 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 971ab6b4..fafba79a 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -238,33 +238,51 @@ def __init__(self, websocket, ports): self.websocket = websocket self.local_ports = {} - for ix, local_remote in enumerate(ports): - self.local_ports[local_remote[0]] = self._Port(ix, local_remote[1]) + for ix, port_number in enumerate(ports): + self.local_ports[port_number] = self._Port(ix, port_number) + # There is a thread run per PortForward instance which performs the translation between the + # raw socket data sent by the python application and the websocket protocol. This thread + # terminates after either side has closed all ports, and after flushing all pending data. threading.Thread( - name="Kubernetes port forward proxy", target=self._proxy, daemon=True + name="Kubernetes port forward proxy: %s" % ', '.join([str(port) for port in ports]), + target=self._proxy, + daemon=True ).start() - def socket(self, local_number): - if local_number not in self.local_ports: + def socket(self, port_number): + if port_number not in self.local_ports: raise ValueError("Invalid port number") - return self.local_ports[local_number].socket + return self.local_ports[port_number].socket - def error(self, local_number): - if local_number not in self.local_ports: + def error(self, port_number): + if port_number not in self.local_ports: raise ValueError("Invalid port number") - return self.local_ports[local_number].error + return self.local_ports[port_number].error def close(self): for port in self.local_ports.values(): port.socket.close() class _Port: - def __init__(self, ix, remote_number): - self.remote_number = remote_number + def __init__(self, ix, port_number): + # The remote port number + self.port_number = port_number + # The websocket channel byte number for this port self.channel = bytes([ix * 2]) + # A socket pair is created to provide a means of translating the data flow + # between the python application and the kubernetes websocket. The self.python + # half of the socket pair is used by the _proxy method to receive and send data + # to the running python application. s, self.python = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + # The self.socket half of the pair is used by the python application to send + # and receive data to the eventual pod port. It is wrapped in the _Socket class + # because a socket pair is an AF_UNIX socket, not a AF_NET socket. This allows + # intercepting setting AF_INET socket options that would error against an AD_UNIX + # socket. self.socket = self._Socket(s) + # Data accumulated from the websocket to be sent to the python application. self.data = b'' + # All data sent from kubernetes on the port error channel. self.error = None class _Socket: @@ -285,8 +303,7 @@ def setsockopt(self, level, optname, value): def _proxy(self): channel_ports = [] channel_initialized = [] - python_ports = {} - rlist = [] + local_ports = {} for port in self.local_ports.values(): # Setup the data channel for this port number channel_ports.append(port) @@ -294,33 +311,36 @@ def _proxy(self): # Setup the error channel for this port number channel_ports.append(port) channel_initialized.append(False) - python_ports[port.python] = port - rlist.append(port.python) - rlist.append(self.websocket.sock) + port.python.setblocking(True) + local_ports[port.python] = port + # The data to send on the websocket socket kubernetes_data = b'' while True: - wlist = [] + rlist = [] # List of sockets to read from + wlist = [] # List of sockets to write to + if self.websocket.connected: + rlist.append(self.websocket) + if kubernetes_data: + wlist.append(self.websocket) + all_closed = True for port in self.local_ports.values(): - if port.data: - wlist.append(port.python) - if kubernetes_data: - wlist.append(self.websocket.sock) + if port.python.fileno() != -1: + if port.data: + wlist.append(port.python) + all_closed = False + else: + if self.websocket.connected: + rlist.append(port.python) + all_closed = False + else: + port.python.close() + if all_closed and (not self.websocket.connected or not kubernetes_data): + self.websocket.close() + return r, w, _ = select.select(rlist, wlist, []) - for s in w: - if s == self.websocket.sock: - sent = self.websocket.sock.send(kubernetes_data) - kubernetes_data = kubernetes_data[sent:] - else: - port = python_ports[s] - sent = port.python.send(port.data) - port.data = port.data[sent:] - for s in r: - if s == self.websocket.sock: + for sock in r: + if sock == self.websocket: opcode, frame = self.websocket.recv_data_frame(True) - if opcode == ABNF.OPCODE_CLOSE: - for port in self.local_ports.values(): - port.python.close() - return if opcode == ABNF.OPCODE_BINARY: if not frame.data: raise RuntimeError("Unexpected frame data size") @@ -341,15 +361,15 @@ def _proxy(self): "Unexpected initial channel frame data size" ) port_number = frame.data[1] + (frame.data[2] * 256) - if port_number != port.remote_number: + if port_number != port.port_number: raise RuntimeError( "Unexpected port number in initial channel frame: " + str(port_number) ) channel_initialized[channel] = True - elif opcode not in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG): + elif opcode not in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG, ABNF.OPCODE_CLOSE): raise RuntimeError("Unexpected websocket opcode: " + str(opcode)) else: - port = python_ports[s] + port = local_ports[sock] data = port.python.recv(1024 * 1024) if data: kubernetes_data += ABNF.create_frame( @@ -357,11 +377,16 @@ def _proxy(self): ABNF.OPCODE_BINARY, ).format() else: - port.python.close() - rlist.remove(s) - if len(rlist) == 1: - self.websocket.close() - return + if not port.data: + port.python.close() + for sock in w: + if sock == self.websocket: + sent = self.websocket.sock.send(kubernetes_data) + kubernetes_data = kubernetes_data[sent:] + else: + port = local_ports[sock] + sent = port.python.send(port.data) + port.data = port.data[sent:] def get_websocket_url(http://github.com/url,%20query_params=None): @@ -451,38 +476,18 @@ def portforward_call(configuration, _method, url, **kwargs): query_params = kwargs.get("query_params") ports = [] - for ix in range(len(query_params)): - if query_params[ix][0] == 'ports': - remote_ports = [] - for port in query_params[ix][1].split(','): + for param, value in query_params: + if param == 'ports': + for port in value.split(','): try: - local_remote = port.split(':') - if len(local_remote) > 2: - raise ValueError - if len(local_remote) == 1: - local_remote[0] = int(local_remote[0]) - if not (0 < local_remote[0] < 65536): - raise ValueError - local_remote.append(local_remote[0]) - elif len(local_remote) == 2: - if local_remote[0]: - local_remote[0] = int(local_remote[0]) - if not (0 <= local_remote[0] < 65536): - raise ValueError - else: - local_remote[0] = 0 - local_remote[1] = int(local_remote[1]) - if not (0 < local_remote[1] < 65536): - raise ValueError - if not local_remote[0]: - local_remote[0] = len(ports) + 1 - else: - raise ValueError - ports.append(local_remote) - remote_ports.append(str(local_remote[1])) + port_number = int(port) except ValueError: - raise ApiValueError("Invalid port number `" + port + "`") - query_params[ix] = ('ports', ','.join(remote_ports)) + raise ApiValueError("Invalid port number: %s" % port) + if not (0 < port_number < 65536): + raise ApiValueError("Port number must be between 0 and 65536: %s" % port) + if port_number in ports: + raise ApiValueError("Duplicate port numbers: %s" % port) + ports.append(port_number) if not ports: raise ApiValueError("Missing required parameter `ports`") From ce3a1a298a1c4d38dfd1e0d228b2eafff2e647a4 Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Mon, 7 Sep 2020 11:56:01 -1000 Subject: [PATCH 078/141] Rework loop which collects the local python sockets for read and writing. --- stream/ws_client.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index fafba79a..b8204599 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -322,19 +322,21 @@ def _proxy(self): rlist.append(self.websocket) if kubernetes_data: wlist.append(self.websocket) - all_closed = True + local_all_closed = True for port in self.local_ports.values(): if port.python.fileno() != -1: - if port.data: - wlist.append(port.python) - all_closed = False + if self.websocket.connected: + rlist.append(port.python) + if port.data: + wlist.append(port.python) + local_all_closed = False else: - if self.websocket.connected: - rlist.append(port.python) - all_closed = False + if port.data: + wlist.append(port.python) + local_all_closed = False else: port.python.close() - if all_closed and (not self.websocket.connected or not kubernetes_data): + if local_all_closed and not (self.websocket.connected and kubernetes_data): self.websocket.close() return r, w, _ = select.select(rlist, wlist, []) From 2e86b713341faaf3309d22f7494b3c68a6a6e04e Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Mon, 7 Sep 2020 13:06:44 -1000 Subject: [PATCH 079/141] Better handling of error channel reponse, and comment typo. --- stream/ws_client.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index b8204599..0f8dc327 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -249,6 +249,10 @@ def __init__(self, websocket, ports): daemon=True ).start() + @property + def connected(self): + return self.websocket.connected + def socket(self, port_number): if port_number not in self.local_ports: raise ValueError("Invalid port number") @@ -276,8 +280,8 @@ def __init__(self, ix, port_number): s, self.python = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) # The self.socket half of the pair is used by the python application to send # and receive data to the eventual pod port. It is wrapped in the _Socket class - # because a socket pair is an AF_UNIX socket, not a AF_NET socket. This allows - # intercepting setting AF_INET socket options that would error against an AD_UNIX + # because a socket pair is an AF_UNIX socket, not a AF_INET socket. This allows + # intercepting setting AF_INET socket options that would error against an AF_UNIX # socket. self.socket = self._Socket(s) # Data accumulated from the websocket to be sent to the python application. @@ -325,17 +329,17 @@ def _proxy(self): local_all_closed = True for port in self.local_ports.values(): if port.python.fileno() != -1: - if self.websocket.connected: - rlist.append(port.python) - if port.data: - wlist.append(port.python) - local_all_closed = False - else: + if port.error or not self.websocket.connected: if port.data: wlist.append(port.python) local_all_closed = False else: port.python.close() + else: + rlist.append(port.python) + if port.data: + wlist.append(port.python) + local_all_closed = False if local_all_closed and not (self.websocket.connected and kubernetes_data): self.websocket.close() return From 5d39d0d5f0e077ea9d19a0f7d94383bed36f7a27 Mon Sep 17 00:00:00 2001 From: "Patrick J. McNerthney" Date: Mon, 7 Sep 2020 19:38:54 -1000 Subject: [PATCH 080/141] Support both python 2.7 and 3.x. --- stream/ws_client.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 0f8dc327..356440c8 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -243,11 +243,12 @@ def __init__(self, websocket, ports): # There is a thread run per PortForward instance which performs the translation between the # raw socket data sent by the python application and the websocket protocol. This thread # terminates after either side has closed all ports, and after flushing all pending data. - threading.Thread( + proxy = threading.Thread( name="Kubernetes port forward proxy: %s" % ', '.join([str(port) for port in ports]), - target=self._proxy, - daemon=True - ).start() + target=self._proxy + ) + proxy.daemon = True + proxy.start() @property def connected(self): @@ -272,7 +273,7 @@ def __init__(self, ix, port_number): # The remote port number self.port_number = port_number # The websocket channel byte number for this port - self.channel = bytes([ix * 2]) + self.channel = six.int2byte(ix * 2) # A socket pair is created to provide a means of translating the data flow # between the python application and the kubernetes websocket. The self.python # half of the socket pair is used by the _proxy method to receive and send data @@ -350,9 +351,9 @@ def _proxy(self): if opcode == ABNF.OPCODE_BINARY: if not frame.data: raise RuntimeError("Unexpected frame data size") - channel = frame.data[0] + channel = six.byte2int(frame.data) if channel >= len(channel_ports): - raise RuntimeError("Unexpected channel number: " + str(channel)) + raise RuntimeError("Unexpected channel number: %s" % channel) port = channel_ports[channel] if channel_initialized[channel]: if channel % 2: @@ -366,14 +367,14 @@ def _proxy(self): raise RuntimeError( "Unexpected initial channel frame data size" ) - port_number = frame.data[1] + (frame.data[2] * 256) + port_number = six.byte2int(frame.data[1:2]) + (six.byte2int(frame.data[2:3]) * 256) if port_number != port.port_number: raise RuntimeError( - "Unexpected port number in initial channel frame: " + str(port_number) + "Unexpected port number in initial channel frame: %s" % port_number ) channel_initialized[channel] = True elif opcode not in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG, ABNF.OPCODE_CLOSE): - raise RuntimeError("Unexpected websocket opcode: " + str(opcode)) + raise RuntimeError("Unexpected websocket opcode: %s" % opcode) else: port = local_ports[sock] data = port.python.recv(1024 * 1024) @@ -383,8 +384,7 @@ def _proxy(self): ABNF.OPCODE_BINARY, ).format() else: - if not port.data: - port.python.close() + port.python.close() for sock in w: if sock == self.websocket: sent = self.websocket.sock.send(kubernetes_data) From bfa968140cb6e7554ecb87e034f519ed2724ba8d Mon Sep 17 00:00:00 2001 From: Dylan Shepard Date: Wed, 9 Sep 2020 07:03:45 -0700 Subject: [PATCH 081/141] supporting 2.7, reading works fine, writing reqs unicode --- config/kube_config_test.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 1f74d345..b2b90ce9 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1262,7 +1262,12 @@ def test_load_kube_config_from_file_like_object(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) config_file_like_object = io.StringIO() - config_file_like_object.write(yaml.safe_dump(self.TEST_KUBE_CONFIG)) + #py3 (won't have unicode) vs py2 (requires it) + try: + unicode('') + config_file_like_object.write(unicode(yaml.safe_dump(self.TEST_KUBE_CONFIG)), errors='replace') + except NameError: + config_file_like_object.write(yaml.safe_dump(self.TEST_KUBE_CONFIG)) actual = FakeConfig() load_kube_config(config_file=config_file_like_object, context="simple_token", client_configuration=actual) From 49cbb1de99ec4bd3213a1f66c8fcd00a55ff761f Mon Sep 17 00:00:00 2001 From: Dylan Shepard Date: Wed, 9 Sep 2020 07:07:13 -0700 Subject: [PATCH 082/141] replace inside the parens --- config/kube_config_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index b2b90ce9..8fcfcc5d 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1265,7 +1265,7 @@ def test_load_kube_config_from_file_like_object(self): #py3 (won't have unicode) vs py2 (requires it) try: unicode('') - config_file_like_object.write(unicode(yaml.safe_dump(self.TEST_KUBE_CONFIG)), errors='replace') + config_file_like_object.write(unicode(yaml.safe_dump(self.TEST_KUBE_CONFIG), errors='replace')) except NameError: config_file_like_object.write(yaml.safe_dump(self.TEST_KUBE_CONFIG)) actual = FakeConfig() From 9f4775f43f8d0d205941a3ae6e1f885d517410aa Mon Sep 17 00:00:00 2001 From: Dylan Shepard Date: Wed, 9 Sep 2020 07:22:04 -0700 Subject: [PATCH 083/141] trying to fix pycodestyle problems --- config/kube_config_test.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 8fcfcc5d..f12a0b3e 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1262,15 +1262,23 @@ def test_load_kube_config_from_file_like_object(self): expected = FakeConfig(host=TEST_HOST, token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64) config_file_like_object = io.StringIO() - #py3 (won't have unicode) vs py2 (requires it) + # py3 (won't have unicode) vs py2 (requires it) try: unicode('') - config_file_like_object.write(unicode(yaml.safe_dump(self.TEST_KUBE_CONFIG), errors='replace')) + config_file_like_object.write( + unicode( + yaml.safe_dump( + self.TEST_KUBE_CONFIG), + errors='replace')) except NameError: - config_file_like_object.write(yaml.safe_dump(self.TEST_KUBE_CONFIG)) + config_file_like_object.write( + yaml.safe_dump( + self.TEST_KUBE_CONFIG)) actual = FakeConfig() - load_kube_config(config_file=config_file_like_object, context="simple_token", - client_configuration=actual) + load_kube_config( + config_file=config_file_like_object, + context="simple_token", + client_configuration=actual) self.assertEqual(expected, actual) def test_load_kube_config_from_dict(self): @@ -1675,7 +1683,6 @@ def test_new_client_from_config(self): self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, client.configuration.api_key['authorization']) - def test_save_changes(self): kubeconfigs = self._create_multi_config() From 0559445cb4a61548b34c68698e37219d837033c9 Mon Sep 17 00:00:00 2001 From: Dylan Shepard Date: Wed, 9 Sep 2020 07:28:51 -0700 Subject: [PATCH 084/141] unused io import, pre-setting pycodestyle --- config/kube_config.py | 1 - config/kube_config_test.py | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 14fd71a6..0ed5a71c 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -19,7 +19,6 @@ import json import logging import os -import io import platform import subprocess import tempfile diff --git a/config/kube_config_test.py b/config/kube_config_test.py index f12a0b3e..de1dcc1b 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -14,9 +14,9 @@ import base64 import datetime +import io import json import os -import io import shutil import tempfile import unittest @@ -1272,13 +1272,13 @@ def test_load_kube_config_from_file_like_object(self): errors='replace')) except NameError: config_file_like_object.write( - yaml.safe_dump( - self.TEST_KUBE_CONFIG)) + yaml.safe_dump( + self.TEST_KUBE_CONFIG)) actual = FakeConfig() load_kube_config( - config_file=config_file_like_object, - context="simple_token", - client_configuration=actual) + config_file=config_file_like_object, + context="simple_token", + client_configuration=actual) self.assertEqual(expected, actual) def test_load_kube_config_from_dict(self): From acdd0588f7fa482a61e41b58c1aa9978069f4d75 Mon Sep 17 00:00:00 2001 From: Haowei Cai Date: Wed, 14 Oct 2020 14:34:17 -0700 Subject: [PATCH 085/141] restore discovery client exception handling --- dynamic/discovery.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dynamic/discovery.py b/dynamic/discovery.py index 24d48d81..d2f801f2 100644 --- a/dynamic/discovery.py +++ b/dynamic/discovery.py @@ -254,8 +254,11 @@ def __search(self, parts, resources, reqParams): # Check if we've requested resources for this group if not resourcePart.resources: prefix, group, version = reqParams[0], reqParams[1], part - resourcePart.resources = self.get_resources_for_api_version( - prefix, group, part, resourcePart.preferred) + try: + resourcePart.resources = self.get_resources_for_api_version( + prefix, group, part, resourcePart.preferred) + except NotFoundError: + raise ResourceNotFoundError self._cache['resources'][prefix][group][version] = resourcePart self.__update_cache = True From 3412151aa96738a1860e3144c2d2a0e87d8e9a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Lipt=C3=A1k?= Date: Mon, 23 Nov 2020 14:09:08 -0500 Subject: [PATCH 086/141] Add Python 3.9 to build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gábor Lipták --- .travis.yml | 4 ++++ tox.ini | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ddff691a..b44ec90a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,3 +44,7 @@ jobs: env: TOXENV=py38 - python: 3.8 env: TOXENV=py38-functional + - python: 3.9 + env: TOXENV=py39 + - python: 3.9 + env: TOXENV=py39-functional diff --git a/tox.ini b/tox.ini index 2d92c46e..71c4d2d8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,8 @@ [tox] skipsdist = True -envlist = py27, py35, py36, py37 +envlist = + py27, py3{5,6,7,8,9} + py27-functional, py3{5,6,7,8,9}-functional [testenv] passenv = TOXENV CI TRAVIS TRAVIS_* From 7199c14a8d12c9aa623a1df2de6bef6c9f6d800a Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Fri, 25 Dec 2020 12:11:42 -0500 Subject: [PATCH 087/141] Change KUBE_CONFIG_DEFAULT_LOCATION to use pathlib.Path.home instead of hard-coded "~". This is a more "Pythonic" way of setting that value. --- config/kube_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 0ed5a71c..5c862287 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -19,6 +19,7 @@ import json import logging import os +import pathlib import platform import subprocess import tempfile @@ -45,7 +46,7 @@ pass EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) -KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') +KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', f'{pathlib.Path.home()}/.kube/config') ENV_KUBECONFIG_PATH_SEPARATOR = ';' if platform.system() == 'Windows' else ':' _temp_files = {} From 0c662bb33dfb49236ca4c68b81d426d8948da224 Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Fri, 25 Dec 2020 12:22:38 -0500 Subject: [PATCH 088/141] Adding load_config wrapper method to have a more generic way of initializing the client config --- config/__init__.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/config/__init__.py b/config/__init__.py index b57bf185..d9d7f4bb 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -12,7 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os from .config_exception import ConfigException from .incluster_config import load_incluster_config from .kube_config import (list_kube_config_contexts, load_kube_config, - load_kube_config_from_dict, new_client_from_config) + load_kube_config_from_dict, new_client_from_config, KUBE_CONFIG_DEFAULT_LOCATION) + + +def load_config(**kwargs): + """ + Wrapper function to load the kube_config. + It will initially try to load_kube_config from provided path, then check if the KUBE_CONFIG_DEFAULT_LOCATION exists + If neither exists- it will fall back to load_incluster_config and inform the user accordingly. + """ + if "kube_config_path" in kwargs.keys() or os.path.exists(KUBE_CONFIG_DEFAULT_LOCATION): + load_kube_config(**kwargs) + else: + print(f"kube_config_path not provided and default location ({KUBE_CONFIG_DEFAULT_LOCATION}) does not exist. " + "Using inCluster Config. This might not work.") + load_incluster_config(**kwargs) From 10db259908b025cfdcbba28c455de9bac54e16aa Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Fri, 25 Dec 2020 12:59:27 -0500 Subject: [PATCH 089/141] Document kwargs param --- config/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/__init__.py b/config/__init__.py index d9d7f4bb..1ff2dec2 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -24,6 +24,9 @@ def load_config(**kwargs): Wrapper function to load the kube_config. It will initially try to load_kube_config from provided path, then check if the KUBE_CONFIG_DEFAULT_LOCATION exists If neither exists- it will fall back to load_incluster_config and inform the user accordingly. + + :param kwargs: A combination of all possible kwargs that can be passed to either load_kube_config or + load_incluster_config functions. """ if "kube_config_path" in kwargs.keys() or os.path.exists(KUBE_CONFIG_DEFAULT_LOCATION): load_kube_config(**kwargs) From 3f05359afce73f3f7bc760d2d718180109bc705a Mon Sep 17 00:00:00 2001 From: Haowei Cai Date: Mon, 4 Jan 2021 17:47:34 -0800 Subject: [PATCH 090/141] configmap-e2e: use labels --- dynamic/test_client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dynamic/test_client.py b/dynamic/test_client.py index 11546798..b68e081f 100644 --- a/dynamic/test_client.py +++ b/dynamic/test_client.py @@ -331,6 +331,9 @@ def test_configmap_apis(self): "apiVersion": "v1", "metadata": { "name": name, + "labels": { + "e2e-test": "true", + }, }, "data": { "config.json": "{\"command\":\"/usr/bin/mysqld_safe\"}", @@ -344,7 +347,7 @@ def test_configmap_apis(self): self.assertEqual(name, resp.metadata.name) resp = api.get( - name=name, namespace='default') + name=name, namespace='default', label_selector="e2e-test=true") self.assertEqual(name, resp.metadata.name) test_configmap['data']['config.json'] = "{}" @@ -354,7 +357,7 @@ def test_configmap_apis(self): resp = api.delete( name=name, body={}, namespace='default') - resp = api.get(namespace='default', pretty=True) + resp = api.get(namespace='default', pretty=True, label_selector="e2e-test=true") self.assertEqual([], resp.items) def test_node_apis(self): From 2c9ddf94b6614c9f16a234de0ce69e01270466c6 Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Mon, 4 Jan 2021 23:58:25 -0500 Subject: [PATCH 091/141] Revert switch to pathlib, to maintain Python2 support --- config/kube_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 5c862287..0ed5a71c 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -19,7 +19,6 @@ import json import logging import os -import pathlib import platform import subprocess import tempfile @@ -46,7 +45,7 @@ pass EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) -KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', f'{pathlib.Path.home()}/.kube/config') +KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') ENV_KUBECONFIG_PATH_SEPARATOR = ';' if platform.system() == 'Windows' else ':' _temp_files = {} From 4d29af161b3d0e8c531c5829da98ef3ee4f03eb1 Mon Sep 17 00:00:00 2001 From: Sumant Date: Fri, 31 Jul 2020 19:09:24 -0400 Subject: [PATCH 092/141] Leader Election issue #434 changed file naming style consistent with the existing go client code Update example.py Changed file and folder names Rename LeaderElection.py to leaderelection.py Rename threadingWithException.py to threadingwithexception.py Rename ConfigMapLock.py to configmaplock.py LeaderElection to leaderelection Added boiler plate headers, updated variable and function names consistent with the guidelines, removed the ctypes dependency by using traces to kill threads, changed logic for leader now it gives up and doesn't re-join as a follower if it fails to update lease added correct boiler plate year Rename threadingWithTrace.py to threadingwithtrace.py Update leaderelection.py Update example.py Changes based on review - logging, OnStoppedLeading is not killed abruptly, OnStartedLeading is not run in a separate thread, adding README Update example.py updated comments set threads as daemon Update README.md Code made consistent with other clients. Update example.py Update leaderelection.py Error & exception handling for the annotation, reduced indentation Adding serializing functions for serializing & de-serializing locks, leader_election_record as a class Adding a test Adding boilerplate header Rename leaderelectiontest.py to leaderelection_test.py Updated boiler plates handling imports for pytest handling 'HTTP not found' compatibility with python 2 & 3, & handling relative imports Update leaderelection.py to check tests for tox assertEquals -> assertEqual Update leaderelection_test.py making Threading compatible for Python 2 changing datetime.timestamp for backward compatibility with Python 2.7 Adding comments for test_Leader_election_with_renew_deadline & making candidates run in parallel for test_leader_election remove redundant daemon = True reassignment common thread lock for MockResourceLock --- leaderelection/README.md | 18 ++ leaderelection/__init__.py | 13 + leaderelection/electionconfig.py | 59 ++++ leaderelection/example.py | 54 ++++ leaderelection/leaderelection.py | 191 +++++++++++++ leaderelection/leaderelection_test.py | 270 +++++++++++++++++++ leaderelection/leaderelectionrecord.py | 22 ++ leaderelection/resourcelock/__init__.py | 13 + leaderelection/resourcelock/configmaplock.py | 129 +++++++++ 9 files changed, 769 insertions(+) create mode 100644 leaderelection/README.md create mode 100644 leaderelection/__init__.py create mode 100644 leaderelection/electionconfig.py create mode 100644 leaderelection/example.py create mode 100644 leaderelection/leaderelection.py create mode 100644 leaderelection/leaderelection_test.py create mode 100644 leaderelection/leaderelectionrecord.py create mode 100644 leaderelection/resourcelock/__init__.py create mode 100644 leaderelection/resourcelock/configmaplock.py diff --git a/leaderelection/README.md b/leaderelection/README.md new file mode 100644 index 00000000..41ed1c48 --- /dev/null +++ b/leaderelection/README.md @@ -0,0 +1,18 @@ +## Leader Election Example +This example demonstrates how to use the leader election library. + +## Running +Run the following command in multiple separate terminals preferably an odd number. +Each running process uses a unique identifier displayed when it starts to run. + +- When a program runs, if a lock object already exists with the specified name, +all candidates will start as followers. +- If a lock object does not exist with the specified name then whichever candidate +creates a lock object first will become the leader and the rest will be followers. +- The user will be prompted about the status of the candidates and transitions. + +### Command to run +```python example.py``` + +Now kill the existing leader. You will see from the terminal outputs that one of the + remaining running processes will be elected as the new leader. diff --git a/leaderelection/__init__.py b/leaderelection/__init__.py new file mode 100644 index 00000000..37da225c --- /dev/null +++ b/leaderelection/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2021 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/leaderelection/electionconfig.py b/leaderelection/electionconfig.py new file mode 100644 index 00000000..7b0db639 --- /dev/null +++ b/leaderelection/electionconfig.py @@ -0,0 +1,59 @@ +# Copyright 2021 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import logging +logging.basicConfig(level=logging.INFO) + + +class Config: + # Validate config, exit if an error is detected + def __init__(self, lock, lease_duration, renew_deadline, retry_period, onstarted_leading, onstopped_leading): + self.jitter_factor = 1.2 + + if lock is None: + sys.exit("lock cannot be None") + self.lock = lock + + if lease_duration <= renew_deadline: + sys.exit("lease_duration must be greater than renew_deadline") + + if renew_deadline <= self.jitter_factor * retry_period: + sys.exit("renewDeadline must be greater than retry_period*jitter_factor") + + if lease_duration < 1: + sys.exit("lease_duration must be greater than one") + + if renew_deadline < 1: + sys.exit("renew_deadline must be greater than one") + + if retry_period < 1: + sys.exit("retry_period must be greater than one") + + self.lease_duration = lease_duration + self.renew_deadline = renew_deadline + self.retry_period = retry_period + + if onstarted_leading is None: + sys.exit("callback onstarted_leading cannot be None") + self.onstarted_leading = onstarted_leading + + if onstopped_leading is None: + self.onstopped_leading = self.on_stoppedleading_callback + else: + self.onstopped_leading = onstopped_leading + + # Default callback for when the current candidate if a leader, stops leading + def on_stoppedleading_callback(self): + logging.info("stopped leading".format(self.lock.identity)) diff --git a/leaderelection/example.py b/leaderelection/example.py new file mode 100644 index 00000000..b8d8e616 --- /dev/null +++ b/leaderelection/example.py @@ -0,0 +1,54 @@ +# Copyright 2021 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid +from kubernetes import client, config +from leaderelection import leaderelection +from leaderelection.resourcelock.configmaplock import ConfigMapLock +from leaderelection import electionconfig + + +# Authenticate using config file +config.load_kube_config(config_file=r"") + +# Parameters required from the user + +# A unique identifier for this candidate +candidate_id = uuid.uuid4() + +# Name of the lock object to be created +lock_name = "examplepython" + +# Kubernetes namespace +lock_namespace = "default" + + +# The function that a user wants to run once a candidate is elected as a leader +def example_func(): + print("I am leader") + + +# A user can choose not to provide any callbacks for what to do when a candidate fails to lead - onStoppedLeading() +# In that case, a default callback function will be used + +# Create config +config = electionconfig.Config(ConfigMapLock(lock_name, lock_namespace, candidate_id), lease_duration=17, + renew_deadline=15, retry_period=5, onstarted_leading=example_func, + onstopped_leading=None) + +# Enter leader election +leaderelection.LeaderElection(config).run() + +# User can choose to do another round of election or simply exit +print("Exited leader election") diff --git a/leaderelection/leaderelection.py b/leaderelection/leaderelection.py new file mode 100644 index 00000000..a707fbac --- /dev/null +++ b/leaderelection/leaderelection.py @@ -0,0 +1,191 @@ +# Copyright 2021 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import sys +import time +import json +import threading +from .leaderelectionrecord import LeaderElectionRecord +import logging +# if condition to be removed when support for python2 will be removed +if sys.version_info > (3, 0): + from http import HTTPStatus +else: + import httplib +logging.basicConfig(level=logging.INFO) + +""" +This package implements leader election using an annotation in a Kubernetes object. +The onstarted_leading function is run in a thread and when it returns, if it does +it might not be safe to run it again in a process. + +At first all candidates are considered followers. The one to create a lock or update +an existing lock first becomes the leader and remains so until it keeps renewing its +lease. +""" + + +class LeaderElection: + def __init__(self, election_config): + if election_config is None: + sys.exit("argument config not passed") + + # Latest record observed in the created lock object + self.observed_record = None + + # The configuration set for this candidate + self.election_config = election_config + + # Latest update time of the lock + self.observed_time_milliseconds = 0 + + # Point of entry to Leader election + def run(self): + # Try to create/ acquire a lock + if self.acquire(): + logging.info("{} successfully acquired lease".format(self.election_config.lock.identity)) + + # Start leading and call OnStartedLeading() + threading.daemon = True + threading.Thread(target=self.election_config.onstarted_leading).start() + + self.renew_loop() + + # Failed to update lease, run OnStoppedLeading callback + self.election_config.onstopped_leading() + + def acquire(self): + # Follower + logging.info("{} is a follower".format(self.election_config.lock.identity)) + retry_period = self.election_config.retry_period + + while True: + succeeded = self.try_acquire_or_renew() + + if succeeded: + return True + + time.sleep(retry_period) + + def renew_loop(self): + # Leader + logging.info("Leader has entered renew loop and will try to update lease continuously") + + retry_period = self.election_config.retry_period + renew_deadline = self.election_config.renew_deadline * 1000 + + while True: + timeout = int(time.time() * 1000) + renew_deadline + succeeded = False + + while int(time.time() * 1000) < timeout: + succeeded = self.try_acquire_or_renew() + + if succeeded: + break + time.sleep(retry_period) + + if succeeded: + time.sleep(retry_period) + continue + + # failed to renew, return + return + + def try_acquire_or_renew(self): + now_timestamp = time.time() + now = datetime.datetime.fromtimestamp(now_timestamp) + + # Check if lock is created + lock_status, old_election_record = self.election_config.lock.get(self.election_config.lock.name, + self.election_config.lock.namespace) + + # create a default Election record for this candidate + leader_election_record = LeaderElectionRecord(self.election_config.lock.identity, + str(self.election_config.lease_duration), str(now), str(now)) + + # A lock is not created with that name, try to create one + if not lock_status: + # To be removed when support for python2 will be removed + if sys.version_info > (3, 0): + if json.loads(old_election_record.body)['code'] != HTTPStatus.NOT_FOUND: + logging.info("Error retrieving resource lock {} as {}".format(self.election_config.lock.name, + old_election_record.reason)) + return False + else: + if json.loads(old_election_record.body)['code'] != httplib.NOT_FOUND: + logging.info("Error retrieving resource lock {} as {}".format(self.election_config.lock.name, + old_election_record.reason)) + return False + + logging.info("{} is trying to create a lock".format(leader_election_record.holder_identity)) + create_status = self.election_config.lock.create(name=self.election_config.lock.name, + namespace=self.election_config.lock.namespace, + election_record=leader_election_record) + + if create_status is False: + logging.info("{} Failed to create lock".format(leader_election_record.holder_identity)) + return False + + self.observed_record = leader_election_record + self.observed_time_milliseconds = int(time.time() * 1000) + return True + + # A lock exists with that name + # Validate old_election_record + if old_election_record is None: + # try to update lock with proper annotation and election record + return self.update_lock(leader_election_record) + + if (old_election_record.holder_identity is None or old_election_record.lease_duration is None + or old_election_record.acquire_time is None or old_election_record.renew_time is None): + # try to update lock with proper annotation and election record + return self.update_lock(leader_election_record) + + # Report transitions + if self.observed_record and self.observed_record.holder_identity != old_election_record.holder_identity: + logging.info("Leader has switched to {}".format(old_election_record.holder_identity)) + + if self.observed_record is None or old_election_record.__dict__ != self.observed_record.__dict__: + self.observed_record = old_election_record + self.observed_time_milliseconds = int(time.time() * 1000) + + # If This candidate is not the leader and lease duration is yet to finish + if (self.election_config.lock.identity != self.observed_record.holder_identity + and self.observed_time_milliseconds + self.election_config.lease_duration * 1000 > int(now_timestamp * 1000)): + logging.info("yet to finish lease_duration, lease held by {} and has not expired".format(old_election_record.holder_identity)) + return False + + # If this candidate is the Leader + if self.election_config.lock.identity == self.observed_record.holder_identity: + # Leader updates renewTime, but keeps acquire_time unchanged + leader_election_record.acquire_time = self.observed_record.acquire_time + + return self.update_lock(leader_election_record) + + def update_lock(self, leader_election_record): + # Update object with latest election record + update_status = self.election_config.lock.update(self.election_config.lock.name, + self.election_config.lock.namespace, + leader_election_record) + + if update_status is False: + logging.info("{} failed to acquire lease".format(leader_election_record.holder_identity)) + return False + + self.observed_record = leader_election_record + self.observed_time_milliseconds = int(time.time() * 1000) + logging.info("leader {} has successfully acquired lease".format(leader_election_record.holder_identity)) + return True diff --git a/leaderelection/leaderelection_test.py b/leaderelection/leaderelection_test.py new file mode 100644 index 00000000..9fb6d9bc --- /dev/null +++ b/leaderelection/leaderelection_test.py @@ -0,0 +1,270 @@ +# Copyright 2021 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from . import leaderelection +from .leaderelectionrecord import LeaderElectionRecord +from kubernetes.client.rest import ApiException +from . import electionconfig +import unittest +import threading +import json +import time +import pytest + +thread_lock = threading.RLock() + +class LeaderElectionTest(unittest.TestCase): + def test_simple_leader_election(self): + election_history = [] + leadership_history = [] + + def on_create(): + election_history.append("create record") + leadership_history.append("get leadership") + + def on_update(): + election_history.append("update record") + + def on_change(): + election_history.append("change record") + + mock_lock = MockResourceLock("mock", "mock_namespace", "mock", thread_lock, on_create, on_update, on_change, None) + + def on_started_leading(): + leadership_history.append("start leading") + + def on_stopped_leading(): + leadership_history.append("stop leading") + + # Create config 4.5 4 3 + config = electionconfig.Config(lock=mock_lock, lease_duration=2.5, + renew_deadline=2, retry_period=1.5, onstarted_leading=on_started_leading, + onstopped_leading=on_stopped_leading) + + # Enter leader election + leaderelection.LeaderElection(config).run() + + self.assert_history(election_history, ["create record", "update record", "update record", "update record"]) + self.assert_history(leadership_history, ["get leadership", "start leading", "stop leading"]) + + def test_leader_election(self): + election_history = [] + leadership_history = [] + + def on_create_A(): + election_history.append("A creates record") + leadership_history.append("A gets leadership") + + def on_update_A(): + election_history.append("A updates record") + + def on_change_A(): + election_history.append("A gets leadership") + + mock_lock_A = MockResourceLock("mock", "mock_namespace", "MockA", thread_lock, on_create_A, on_update_A, on_change_A, None) + mock_lock_A.renew_count_max = 3 + + def on_started_leading_A(): + leadership_history.append("A starts leading") + + def on_stopped_leading_A(): + leadership_history.append("A stops leading") + + config_A = electionconfig.Config(lock=mock_lock_A, lease_duration=2.5, + renew_deadline=2, retry_period=1.5, onstarted_leading=on_started_leading_A, + onstopped_leading=on_stopped_leading_A) + + def on_create_B(): + election_history.append("B creates record") + leadership_history.append("B gets leadership") + + def on_update_B(): + election_history.append("B updates record") + + def on_change_B(): + leadership_history.append("B gets leadership") + + mock_lock_B = MockResourceLock("mock", "mock_namespace", "MockB", thread_lock, on_create_B, on_update_B, on_change_B, None) + mock_lock_B.renew_count_max = 4 + + def on_started_leading_B(): + leadership_history.append("B starts leading") + + def on_stopped_leading_B(): + leadership_history.append("B stops leading") + + config_B = electionconfig.Config(lock=mock_lock_B, lease_duration=2.5, + renew_deadline=2, retry_period=1.5, onstarted_leading=on_started_leading_B, + onstopped_leading=on_stopped_leading_B) + + mock_lock_B.leader_record = mock_lock_A.leader_record + + threading.daemon = True + # Enter leader election for A + threading.Thread(target=leaderelection.LeaderElection(config_A).run()).start() + + # Enter leader election for B + threading.Thread(target=leaderelection.LeaderElection(config_B).run()).start() + + time.sleep(5) + + self.assert_history(election_history, + ["A creates record", + "A updates record", + "A updates record", + "B updates record", + "B updates record", + "B updates record", + "B updates record"]) + self.assert_history(leadership_history, + ["A gets leadership", + "A starts leading", + "A stops leading", + "B gets leadership", + "B starts leading", + "B stops leading"]) + + + """Expected behavior: to check if the leader stops leading if it fails to update the lock within the renew_deadline + and stops leading after finally timing out. The difference between each try comes out to be approximately the sleep + time. + Example: + create record: 0s + on try update: 1.5s + on update: zzz s + on try update: 3s + on update: zzz s + on try update: 4.5s + on try update: 6s + Timeout - Leader Exits""" + def test_Leader_election_with_renew_deadline(self): + election_history = [] + leadership_history = [] + + def on_create(): + election_history.append("create record") + leadership_history.append("get leadership") + + def on_update(): + election_history.append("update record") + + def on_change(): + election_history.append("change record") + + def on_try_update(): + election_history.append("try update record") + + mock_lock = MockResourceLock("mock", "mock_namespace", "mock", thread_lock, on_create, on_update, on_change, on_try_update) + mock_lock.renew_count_max = 3 + + def on_started_leading(): + leadership_history.append("start leading") + + def on_stopped_leading(): + leadership_history.append("stop leading") + + # Create config + config = electionconfig.Config(lock=mock_lock, lease_duration=2.5, + renew_deadline=2, retry_period=1.5, onstarted_leading=on_started_leading, + onstopped_leading=on_stopped_leading) + + # Enter leader election + leaderelection.LeaderElection(config).run() + + self.assert_history(election_history, + ["create record", + "try update record", + "update record", + "try update record", + "update record", + "try update record", + "try update record"]) + + self.assert_history(leadership_history, ["get leadership", "start leading", "stop leading"]) + + def assert_history(self, history, expected): + self.assertIsNotNone(expected) + self.assertIsNotNone(history) + self.assertEqual(len(expected), len(history)) + + for idx in range(len(history)): + self.assertEqual(history[idx], expected[idx], + msg="Not equal at index {}, expected {}, got {}".format(idx, expected[idx], + history[idx])) + + +class MockResourceLock: + def __init__(self, name, namespace, identity, shared_lock, on_create=None, on_update=None, on_change=None, on_try_update=None): + # self.leader_record is shared between two MockResourceLock objects + self.leader_record = [] + self.renew_count = 0 + self.renew_count_max = 4 + self.name = name + self.namespace = namespace + self.identity = str(identity) + self.lock = shared_lock + + self.on_create = on_create + self.on_update = on_update + self.on_change = on_change + self.on_try_update = on_try_update + + def get(self, name, namespace): + self.lock.acquire() + try: + if self.leader_record: + return True, self.leader_record[0] + + ApiException.body = json.dumps({'code': 404}) + return False, ApiException + finally: + self.lock.release() + + def create(self, name, namespace, election_record): + self.lock.acquire() + try: + if len(self.leader_record) == 1: + return False + self.leader_record.append(election_record) + self.on_create() + self.renew_count += 1 + return True + finally: + self.lock.release() + + def update(self, name, namespace, updated_record): + self.lock.acquire() + try: + if self.on_try_update: + self.on_try_update() + if self.renew_count >= self.renew_count_max: + return False + + old_record = self.leader_record[0] + self.leader_record[0] = updated_record + + self.on_update() + + if old_record.holder_identity != updated_record.holder_identity: + self.on_change() + + self.renew_count += 1 + return True + finally: + self.lock.release() + + +if __name__ == '__main__': + unittest.main() diff --git a/leaderelection/leaderelectionrecord.py b/leaderelection/leaderelectionrecord.py new file mode 100644 index 00000000..ebb550d4 --- /dev/null +++ b/leaderelection/leaderelectionrecord.py @@ -0,0 +1,22 @@ +# Copyright 2021 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class LeaderElectionRecord: + # Annotation used in the lock object + def __init__(self, holder_identity, lease_duration, acquire_time, renew_time): + self.holder_identity = holder_identity + self.lease_duration = lease_duration + self.acquire_time = acquire_time + self.renew_time = renew_time diff --git a/leaderelection/resourcelock/__init__.py b/leaderelection/resourcelock/__init__.py new file mode 100644 index 00000000..37da225c --- /dev/null +++ b/leaderelection/resourcelock/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2021 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/leaderelection/resourcelock/configmaplock.py b/leaderelection/resourcelock/configmaplock.py new file mode 100644 index 00000000..8d155e29 --- /dev/null +++ b/leaderelection/resourcelock/configmaplock.py @@ -0,0 +1,129 @@ +# Copyright 2021 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from kubernetes.client.rest import ApiException +from kubernetes import client, config +from kubernetes.client.api_client import ApiClient +from leaderelection.leaderelectionrecord import LeaderElectionRecord +import json +import logging +logging.basicConfig(level=logging.INFO) + + +class ConfigMapLock: + def __init__(self, name, namespace, identity): + """ + :param name: name of the lock + :param namespace: namespace + :param identity: A unique identifier that the candidate is using + """ + self.api_instance = client.CoreV1Api() + self.leader_electionrecord_annotationkey = 'control-plane.alpha.kubernetes.io/leader' + self.name = name + self.namespace = namespace + self.identity = str(identity) + self.configmap_reference = None + self.lock_record = { + 'holderIdentity': None, + 'leaseDurationSeconds': None, + 'acquireTime': None, + 'renewTime': None + } + + # get returns the election record from a ConfigMap Annotation + def get(self, name, namespace): + """ + :param name: Name of the configmap object information to get + :param namespace: Namespace in which the configmap object is to be searched + :return: 'True, election record' if object found else 'False, exception response' + """ + try: + api_response = self.api_instance.read_namespaced_config_map(name, namespace) + + # If an annotation does not exist - add the leader_electionrecord_annotationkey + annotations = api_response.metadata.annotations + if annotations is None or annotations == '': + api_response.metadata.annotations = {self.leader_electionrecord_annotationkey: ''} + self.configmap_reference = api_response + return True, None + + # If an annotation exists but, the leader_electionrecord_annotationkey does not then add it as a key + if not annotations.get(self.leader_electionrecord_annotationkey): + api_response.metadata.annotations = {self.leader_electionrecord_annotationkey: ''} + self.configmap_reference = api_response + return True, None + + lock_record = self.get_lock_object(json.loads(annotations[self.leader_electionrecord_annotationkey])) + + self.configmap_reference = api_response + return True, lock_record + except ApiException as e: + return False, e + + def create(self, name, namespace, election_record): + """ + :param electionRecord: Annotation string + :param name: Name of the configmap object to be created + :param namespace: Namespace in which the configmap object is to be created + :return: 'True' if object is created else 'False' if failed + """ + body = client.V1ConfigMap( + metadata={"name": name, + "annotations": {self.leader_electionrecord_annotationkey: json.dumps(self.get_lock_dict(election_record))}}) + + try: + api_response = self.api_instance.create_namespaced_config_map(namespace, body, pretty=True) + return True + except ApiException as e: + logging.info("Failed to create lock as {}".format(e)) + return False + + def update(self, name, namespace, updated_record): + """ + :param name: name of the lock to be updated + :param namespace: namespace the lock is in + :param updated_record: the updated election record + :return: True if update is succesful False if it fails + """ + try: + # Set the updated record + self.configmap_reference.metadata.annotations[self.leader_electionrecord_annotationkey] = json.dumps(self.get_lock_dict(updated_record)) + api_response = self.api_instance.replace_namespaced_config_map(name=name, namespace=namespace, + body=self.configmap_reference) + return True + except ApiException as e: + logging.info("Failed to update lock as {}".format(e)) + return False + + def get_lock_object(self, lock_record): + leader_election_record = LeaderElectionRecord(None, None, None, None) + + if lock_record.get('holderIdentity'): + leader_election_record.holder_identity = lock_record['holderIdentity'] + if lock_record.get('leaseDurationSeconds'): + leader_election_record.lease_duration = lock_record['leaseDurationSeconds'] + if lock_record.get('acquireTime'): + leader_election_record.acquire_time = lock_record['acquireTime'] + if lock_record.get('renewTime'): + leader_election_record.renew_time = lock_record['renewTime'] + + return leader_election_record + + def get_lock_dict(self, leader_election_record): + self.lock_record['holderIdentity'] = leader_election_record.holder_identity + self.lock_record['leaseDurationSeconds'] = leader_election_record.lease_duration + self.lock_record['acquireTime'] = leader_election_record.acquire_time + self.lock_record['renewTime'] = leader_election_record.renew_time + + return self.lock_record \ No newline at end of file From 8a3bdb8ebe8dc59037671fec70227d09ab34bf45 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Sat, 23 Jan 2021 19:23:45 +0100 Subject: [PATCH 093/141] take care of empty kube_config files --- config/kube_config.py | 8 ++++++++ config/kube_config_test.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/config/kube_config.py b/config/kube_config.py index 0ed5a71c..b90dbd02 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -682,6 +682,9 @@ def _load_config_from_file_like_object(self, string): else: config = yaml.safe_load(string.read()) + if config is None: + raise ConfigException( + 'Invalid kube-config.') if self.config_merged is None: self.config_merged = copy.deepcopy(config) # doesn't need to do any further merging @@ -699,6 +702,11 @@ def load_config(self, path): with open(path) as f: config = yaml.safe_load(f) + if config is None: + raise ConfigException( + 'Invalid kube-config. ' + '%s file is empty' % path) + if self.config_merged is None: config_merged = copy.deepcopy(config) for item in ('clusters', 'contexts', 'users'): diff --git a/config/kube_config_test.py b/config/kube_config_test.py index de1dcc1b..a4d47fc7 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1290,6 +1290,15 @@ def test_load_kube_config_from_dict(self): client_configuration=actual) self.assertEqual(expected, actual) + def test_load_kube_config_from_empty_file(self): + config_file_like_object = io.StringIO() + self.assertRaises(ConfigException, load_kube_config, config_file_like_object) + + def test_load_kube_config_from_empty_file_like_object(self): + config_file = self._create_temp_file( + yaml.safe_dump(None)) + self.assertRaises(ConfigException, load_kube_config, config_file) + def test_list_kube_config_contexts(self): config_file = self._create_temp_file( yaml.safe_dump(self.TEST_KUBE_CONFIG)) From 76d8fbda2832eb9fbdb87af46495179152c42e3f Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Mon, 25 Jan 2021 13:43:26 +0100 Subject: [PATCH 094/141] fix codestyle --- config/kube_config_test.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index a4d47fc7..f18e5e0f 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1292,12 +1292,18 @@ def test_load_kube_config_from_dict(self): def test_load_kube_config_from_empty_file(self): config_file_like_object = io.StringIO() - self.assertRaises(ConfigException, load_kube_config, config_file_like_object) + self.assertRaises( + ConfigException, + load_kube_config, + config_file_like_object) def test_load_kube_config_from_empty_file_like_object(self): config_file = self._create_temp_file( yaml.safe_dump(None)) - self.assertRaises(ConfigException, load_kube_config, config_file) + self.assertRaises( + ConfigException, + load_kube_config, + config_file) def test_list_kube_config_contexts(self): config_file = self._create_temp_file( From 18a5ccc3ef621e85a8d02249270bad0a46e3addc Mon Sep 17 00:00:00 2001 From: jamesgetx Date: Fri, 29 Jan 2021 17:27:01 +0800 Subject: [PATCH 095/141] fix: load cache error when CacheDecoder object is not callable --- dynamic/discovery.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dynamic/discovery.py b/dynamic/discovery.py index d2f801f2..5c2f4ac4 100644 --- a/dynamic/discovery.py +++ b/dynamic/discovery.py @@ -15,8 +15,10 @@ import os import six import json +import logging import hashlib import tempfile +from functools import partial from collections import defaultdict from abc import abstractmethod, abstractproperty @@ -54,11 +56,12 @@ def __init_cache(self, refresh=False): else: try: with open(self.__cache_file, 'r') as f: - self._cache = json.load(f, cls=CacheDecoder(self.client)) + self._cache = json.load(f, cls=partial(CacheDecoder, self.client)) if self._cache.get('library_version') != __version__: # Version mismatch, need to refresh cache self.invalidate_cache() - except Exception: + except Exception as e: + logging.error("load cache error: %s", e) self.invalidate_cache() self._load_server_info() self.discover() From ec1e85ec105bd05404bcec728a57bed0e74a8d1f Mon Sep 17 00:00:00 2001 From: jamesgetx Date: Mon, 1 Feb 2021 21:18:40 +0800 Subject: [PATCH 096/141] test: self._cache = json.load(f, cls=partial(CacheDecoder, self.client)) --- dynamic/test_discovery.py | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 dynamic/test_discovery.py diff --git a/dynamic/test_discovery.py b/dynamic/test_discovery.py new file mode 100644 index 00000000..ef3cd8e1 --- /dev/null +++ b/dynamic/test_discovery.py @@ -0,0 +1,40 @@ +# Copyright 2019 The Kubernetes Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest + +from kubernetes.e2e_test import base +from kubernetes.client import api_client + +from . import DynamicClient + + +class TestDiscoverer(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.config = base.get_e2e_configuration() + + def test_init_cache_from_file(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + client.resources.get(api_version='v1', kind='Node') + mtime1 = os.path.getmtime(client.resources._Discoverer__cache_file) + + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + client.resources.get(api_version='v1', kind='Node') + mtime2 = os.path.getmtime(client.resources._Discoverer__cache_file) + + # test no Discoverer._write_cache called + self.assertTrue(mtime1 == mtime2) \ No newline at end of file From e09312a31e8bb12759421a49088f350ed2735b52 Mon Sep 17 00:00:00 2001 From: jamesgetx Date: Mon, 1 Feb 2021 21:20:19 +0800 Subject: [PATCH 097/141] test: self._cache = json.load(f, cls=partial(CacheDecoder, self.client)) --- dynamic/test_discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dynamic/test_discovery.py b/dynamic/test_discovery.py index ef3cd8e1..4897f244 100644 --- a/dynamic/test_discovery.py +++ b/dynamic/test_discovery.py @@ -37,4 +37,4 @@ def test_init_cache_from_file(self): mtime2 = os.path.getmtime(client.resources._Discoverer__cache_file) # test no Discoverer._write_cache called - self.assertTrue(mtime1 == mtime2) \ No newline at end of file + self.assertTrue(mtime1 == mtime2) From 3c719874c6278ec4cc5ac3110951ce149fc72d66 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Mon, 1 Feb 2021 19:13:29 +0100 Subject: [PATCH 098/141] fix typo in test file --- config/kube_config_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index f18e5e0f..9d299e9d 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1290,14 +1290,14 @@ def test_load_kube_config_from_dict(self): client_configuration=actual) self.assertEqual(expected, actual) - def test_load_kube_config_from_empty_file(self): + def test_load_kube_config_from_empty_file_like_object(self): config_file_like_object = io.StringIO() self.assertRaises( ConfigException, load_kube_config, config_file_like_object) - def test_load_kube_config_from_empty_file_like_object(self): + def test_load_kube_config_from_empty_file(self): config_file = self._create_temp_file( yaml.safe_dump(None)) self.assertRaises( From ebea7e343046d7afbbdc0e199294d5c79ae87362 Mon Sep 17 00:00:00 2001 From: Chris Ayoub Date: Thu, 25 Feb 2021 00:27:33 -0500 Subject: [PATCH 099/141] Fix Watch retries with 410 errors --- watch/watch.py | 10 +++++---- watch/watch_test.py | 54 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/watch/watch.py b/watch/watch.py index 3058ed9a..b432778e 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -151,7 +151,9 @@ def stream(self, func, *args, **kwargs): if 'resource_version' in kwargs: self.resource_version = kwargs['resource_version'] - timeouts = ('timeout_seconds' in kwargs) + # Do not attempt retries if user specifies a timeout. + # We want to ensure we are returning within that timeout. + disable_retries = ('timeout_seconds' in kwargs) retry_after_410 = False while True: resp = func(*args, **kwargs) @@ -164,9 +166,9 @@ def stream(self, func, *args, **kwargs): if isinstance(event, dict) \ and event['type'] == 'ERROR': obj = event['raw_object'] - # Current request expired, let's retry, + # Current request expired, let's retry, (if enabled) # but only if we have not already retried. - if not retry_after_410 and \ + if not disable_retries and not retry_after_410 and \ obj['code'] == HTTP_STATUS_GONE: retry_after_410 = True break @@ -190,5 +192,5 @@ def stream(self, func, *args, **kwargs): else: self._stop = True - if timeouts or self._stop: + if self._stop or disable_retries: break diff --git a/watch/watch_test.py b/watch/watch_test.py index b8cefd20..32cf6334 100644 --- a/watch/watch_test.py +++ b/watch/watch_test.py @@ -287,15 +287,65 @@ def test_watch_with_error_event(self): fake_api = Mock() fake_api.get_thing = Mock(return_value=fake_resp) + w = Watch() + # No events are generated when no initial resourceVersion is passed + # No retry is attempted either, preventing an ApiException + assert not list(w.stream(fake_api.get_thing)) + + fake_api.get_thing.assert_called_once_with( + _preload_content=False, watch=True) + fake_resp.read_chunked.assert_called_once_with(decode_content=False) + fake_resp.close.assert_called_once() + fake_resp.release_conn.assert_called_once() + + def test_watch_retries_on_error_event(self): + fake_resp = Mock() + fake_resp.close = Mock() + fake_resp.release_conn = Mock() + fake_resp.read_chunked = Mock( + return_value=[ + '{"type": "ERROR", "object": {"code": 410, ' + '"reason": "Gone", "message": "error message"}}\n']) + + fake_api = Mock() + fake_api.get_thing = Mock(return_value=fake_resp) + w = Watch() try: - for _ in w.stream(fake_api.get_thing): + for _ in w.stream(fake_api.get_thing, resource_version=0): + self.fail(self, "Should fail with ApiException.") + except client.rest.ApiException: + pass + + # Two calls should be expected during a retry + fake_api.get_thing.assert_has_calls( + [call(resource_version=0, _preload_content=False, watch=True)] * 2) + fake_resp.read_chunked.assert_has_calls( + [call(decode_content=False)] * 2) + assert fake_resp.close.call_count == 2 + assert fake_resp.release_conn.call_count == 2 + + def test_watch_with_error_event_and_timeout_param(self): + fake_resp = Mock() + fake_resp.close = Mock() + fake_resp.release_conn = Mock() + fake_resp.read_chunked = Mock( + return_value=[ + '{"type": "ERROR", "object": {"code": 410, ' + '"reason": "Gone", "message": "error message"}}\n']) + + fake_api = Mock() + fake_api.get_thing = Mock(return_value=fake_resp) + + w = Watch() + try: + for _ in w.stream(fake_api.get_thing, timeout_seconds=10): self.fail(self, "Should fail with ApiException.") except client.rest.ApiException: pass fake_api.get_thing.assert_called_once_with( - _preload_content=False, watch=True) + _preload_content=False, watch=True, timeout_seconds=10) fake_resp.read_chunked.assert_called_once_with(decode_content=False) fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() From 2ea3efbc628597ed3ed2bf3c16e684727addd75b Mon Sep 17 00:00:00 2001 From: Bob Killen Date: Sun, 28 Feb 2021 15:05:02 -0500 Subject: [PATCH 100/141] Remove inactive members from OWNERS As a part of cleaning up inactive members (those with no activity within the past 18 months) from OWNERS files, this commit moves mbohlool from an approver to emeritus_approver. --- OWNERS | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/OWNERS b/OWNERS index cfec4b11..c331e688 100644 --- a/OWNERS +++ b/OWNERS @@ -1,6 +1,8 @@ # See the OWNERS docs at https://go.k8s.io/owners approvers: - - mbohlool - yliaog - roycaihw +emeritus_approvers: + - mbohlool + From ed98daeae96c6dc4f245421497c390c009dcec72 Mon Sep 17 00:00:00 2001 From: Tom Haddon Date: Fri, 19 Mar 2021 05:56:27 +0100 Subject: [PATCH 101/141] Fix trivial typo in error messages - 'does not exist' vs. 'does not exists' --- config/incluster_config.py | 4 ++-- config/incluster_config_test.py | 4 ++-- config/kube_config.py | 2 +- config/kube_config_test.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config/incluster_config.py b/config/incluster_config.py index 288a2688..5dabd4b7 100644 --- a/config/incluster_config.py +++ b/config/incluster_config.py @@ -70,13 +70,13 @@ def _load_config(self): self._environ[SERVICE_PORT_ENV_NAME])) if not os.path.isfile(self._token_filename): - raise ConfigException("Service token file does not exists.") + raise ConfigException("Service token file does not exist.") self._read_token_file() if not os.path.isfile(self._cert_filename): raise ConfigException( - "Service certification file does not exists.") + "Service certification file does not exist.") with open(self._cert_filename) as f: if not f.read(): diff --git a/config/incluster_config_test.py b/config/incluster_config_test.py index ef7468d7..856752be 100644 --- a/config/incluster_config_test.py +++ b/config/incluster_config_test.py @@ -142,7 +142,7 @@ def test_empty_host(self): def test_no_cert_file(self): loader = self.get_test_loader(cert_filename="not_exists_file_1123") - self._should_fail_load(loader, "cert file does not exists") + self._should_fail_load(loader, "cert file does not exist") def test_empty_cert_file(self): loader = self.get_test_loader( @@ -151,7 +151,7 @@ def test_empty_cert_file(self): def test_no_token_file(self): loader = self.get_test_loader(token_filename="not_exists_file_1123") - self._should_fail_load(loader, "token file does not exists") + self._should_fail_load(loader, "token file does not exist") def test_empty_token_file(self): loader = self.get_test_loader( diff --git a/config/kube_config.py b/config/kube_config.py index b90dbd02..61a261f6 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -120,7 +120,7 @@ def as_file(self): else: self._file = _create_temp_file_with_content(self._data) if self._file and not os.path.isfile(self._file): - raise ConfigException("File does not exists: %s" % self._file) + raise ConfigException("File does not exist: %s" % self._file) return self._file def as_data(self): diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 9d299e9d..a82ef40e 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -178,7 +178,7 @@ def test_file_given_non_existing_file(self): temp_filename = NON_EXISTING_FILE obj = {TEST_FILE_KEY: temp_filename} t = FileOrData(obj=obj, file_key_name=TEST_FILE_KEY) - self.expect_exception(t.as_file, "does not exists") + self.expect_exception(t.as_file, "does not exist") def test_file_given_data(self): obj = {TEST_DATA_KEY: TEST_DATA_BASE64} @@ -1165,7 +1165,7 @@ def test_ssl_no_cert_files(self): active_context="ssl-no_file") self.expect_exception( loader.load_and_set, - "does not exists", + "does not exist", FakeConfig()) def test_ssl(self): From 9bce8696ffb10e30757e93e72d5c4970d5144c16 Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Sun, 21 Mar 2021 23:01:35 -0400 Subject: [PATCH 102/141] Switching print statement to use legacy .format() method, in order to maintain backwards-compatibility with pre-3.6 Python versions --- config/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 1ff2dec2..204819eb 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -31,6 +31,6 @@ def load_config(**kwargs): if "kube_config_path" in kwargs.keys() or os.path.exists(KUBE_CONFIG_DEFAULT_LOCATION): load_kube_config(**kwargs) else: - print(f"kube_config_path not provided and default location ({KUBE_CONFIG_DEFAULT_LOCATION}) does not exist. " - "Using inCluster Config. This might not work.") + print("kube_config_path not provided and default location ({0}) does not exist. " + "Using inCluster Config. This might not work.".format(KUBE_CONFIG_DEFAULT_LOCATION)) load_incluster_config(**kwargs) From 0395a107185cef66592dfd26dbb8118179d272c4 Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Sun, 21 Mar 2021 23:27:47 -0400 Subject: [PATCH 103/141] Run black linter to make update-pycodestyle happy --- config/__init__.py | 21 +++++++++++---- watch/watch.py | 64 ++++++++++++++++++++++++---------------------- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 204819eb..2ab141cd 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -15,8 +15,13 @@ import os from .config_exception import ConfigException from .incluster_config import load_incluster_config -from .kube_config import (list_kube_config_contexts, load_kube_config, - load_kube_config_from_dict, new_client_from_config, KUBE_CONFIG_DEFAULT_LOCATION) +from .kube_config import ( + list_kube_config_contexts, + load_kube_config, + load_kube_config_from_dict, + new_client_from_config, + KUBE_CONFIG_DEFAULT_LOCATION, +) def load_config(**kwargs): @@ -28,9 +33,15 @@ def load_config(**kwargs): :param kwargs: A combination of all possible kwargs that can be passed to either load_kube_config or load_incluster_config functions. """ - if "kube_config_path" in kwargs.keys() or os.path.exists(KUBE_CONFIG_DEFAULT_LOCATION): + if "kube_config_path" in kwargs.keys() or os.path.exists( + KUBE_CONFIG_DEFAULT_LOCATION + ): load_kube_config(**kwargs) else: - print("kube_config_path not provided and default location ({0}) does not exist. " - "Using inCluster Config. This might not work.".format(KUBE_CONFIG_DEFAULT_LOCATION)) + print( + "kube_config_path not provided and default location ({0}) does not exist. " + "Using inCluster Config. This might not work.".format( + KUBE_CONFIG_DEFAULT_LOCATION + ) + ) load_incluster_config(**kwargs) diff --git a/watch/watch.py b/watch/watch.py index b432778e..4047be0f 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -32,14 +32,15 @@ PY2 = sys.version_info[0] == 2 if PY2: import httplib + HTTP_STATUS_GONE = httplib.GONE else: import http + HTTP_STATUS_GONE = http.HTTPStatus.GONE class SimpleNamespace: - def __init__(self, **kwargs): self.__dict__.update(kwargs) @@ -47,7 +48,7 @@ def __init__(self, **kwargs): def _find_return_type(func): for line in pydoc.getdoc(func).splitlines(): if line.startswith(PYDOC_RETURN_LABEL): - return line[len(PYDOC_RETURN_LABEL):].strip() + return line[len(PYDOC_RETURN_LABEL) :].strip() return "" @@ -55,7 +56,7 @@ def iter_resp_lines(resp): prev = "" for seg in resp.read_chunked(decode_content=False): if isinstance(seg, bytes): - seg = seg.decode('utf8') + seg = seg.decode("utf8") seg = prev + seg lines = seg.split("\n") if not seg.endswith("\n"): @@ -69,7 +70,6 @@ def iter_resp_lines(resp): class Watch(object): - def __init__(self, return_type=None): self._raw_return_type = return_type self._stop = False @@ -84,29 +84,31 @@ def get_return_type(self, func): return self._raw_return_type return_type = _find_return_type(func) if return_type.endswith(TYPE_LIST_SUFFIX): - return return_type[:-len(TYPE_LIST_SUFFIX)] + return return_type[: -len(TYPE_LIST_SUFFIX)] return return_type def get_watch_argument_name(self, func): if PYDOC_FOLLOW_PARAM in pydoc.getdoc(func): - return 'follow' + return "follow" else: - return 'watch' + return "watch" def unmarshal_event(self, data, return_type): js = json.loads(data) - js['raw_object'] = js['object'] - if return_type and js['type'] != 'ERROR': - obj = SimpleNamespace(data=json.dumps(js['raw_object'])) - js['object'] = self._api_client.deserialize(obj, return_type) - if hasattr(js['object'], 'metadata'): - self.resource_version = js['object'].metadata.resource_version + js["raw_object"] = js["object"] + if return_type and js["type"] != "ERROR": + obj = SimpleNamespace(data=json.dumps(js["raw_object"])) + js["object"] = self._api_client.deserialize(obj, return_type) + if hasattr(js["object"], "metadata"): + self.resource_version = js["object"].metadata.resource_version # For custom objects that we don't have model defined, json # deserialization results in dictionary - elif (isinstance(js['object'], dict) and 'metadata' in js['object'] - and 'resourceVersion' in js['object']['metadata']): - self.resource_version = js['object']['metadata'][ - 'resourceVersion'] + elif ( + isinstance(js["object"], dict) + and "metadata" in js["object"] + and "resourceVersion" in js["object"]["metadata"] + ): + self.resource_version = js["object"]["metadata"]["resourceVersion"] return js def stream(self, func, *args, **kwargs): @@ -147,13 +149,13 @@ def stream(self, func, *args, **kwargs): return_type = self.get_return_type(func) watch_arg = self.get_watch_argument_name(func) kwargs[watch_arg] = True - kwargs['_preload_content'] = False - if 'resource_version' in kwargs: - self.resource_version = kwargs['resource_version'] + kwargs["_preload_content"] = False + if "resource_version" in kwargs: + self.resource_version = kwargs["resource_version"] # Do not attempt retries if user specifies a timeout. # We want to ensure we are returning within that timeout. - disable_retries = ('timeout_seconds' in kwargs) + disable_retries = "timeout_seconds" in kwargs retry_after_410 = False while True: resp = func(*args, **kwargs) @@ -163,20 +165,22 @@ def stream(self, func, *args, **kwargs): # return raw string when we are streaming log if watch_arg == "watch": event = self.unmarshal_event(line, return_type) - if isinstance(event, dict) \ - and event['type'] == 'ERROR': - obj = event['raw_object'] + if isinstance(event, dict) and event["type"] == "ERROR": + obj = event["raw_object"] # Current request expired, let's retry, (if enabled) # but only if we have not already retried. - if not disable_retries and not retry_after_410 and \ - obj['code'] == HTTP_STATUS_GONE: + if ( + not disable_retries + and not retry_after_410 + and obj["code"] == HTTP_STATUS_GONE + ): retry_after_410 = True break else: - reason = "%s: %s" % ( - obj['reason'], obj['message']) + reason = "%s: %s" % (obj["reason"], obj["message"]) raise client.rest.ApiException( - status=obj['code'], reason=reason) + status=obj["code"], reason=reason + ) else: retry_after_410 = False yield event @@ -188,7 +192,7 @@ def stream(self, func, *args, **kwargs): resp.close() resp.release_conn() if self.resource_version is not None: - kwargs['resource_version'] = self.resource_version + kwargs["resource_version"] = self.resource_version else: self._stop = True From 34b8304d5fe0b95df8b9968f766cf9e8598e778a Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Sun, 21 Mar 2021 23:38:48 -0400 Subject: [PATCH 104/141] autopep8 --- watch/watch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/watch/watch.py b/watch/watch.py index 4047be0f..7a143f7e 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -48,7 +48,7 @@ def __init__(self, **kwargs): def _find_return_type(func): for line in pydoc.getdoc(func).splitlines(): if line.startswith(PYDOC_RETURN_LABEL): - return line[len(PYDOC_RETURN_LABEL) :].strip() + return line[len(PYDOC_RETURN_LABEL):].strip() return "" @@ -177,7 +177,8 @@ def stream(self, func, *args, **kwargs): retry_after_410 = True break else: - reason = "%s: %s" % (obj["reason"], obj["message"]) + reason = "%s: %s" % ( + obj["reason"], obj["message"]) raise client.rest.ApiException( status=obj["code"], reason=reason ) From 0a5b04feead64f73ae042665251e3aef5e35f84e Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Sun, 21 Mar 2021 23:44:32 -0400 Subject: [PATCH 105/141] Revert black and only try autopep8 this time --- config/__init__.py | 21 ++++------------ watch/watch.py | 61 +++++++++++++++++++++------------------------- 2 files changed, 33 insertions(+), 49 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 2ab141cd..204819eb 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -15,13 +15,8 @@ import os from .config_exception import ConfigException from .incluster_config import load_incluster_config -from .kube_config import ( - list_kube_config_contexts, - load_kube_config, - load_kube_config_from_dict, - new_client_from_config, - KUBE_CONFIG_DEFAULT_LOCATION, -) +from .kube_config import (list_kube_config_contexts, load_kube_config, + load_kube_config_from_dict, new_client_from_config, KUBE_CONFIG_DEFAULT_LOCATION) def load_config(**kwargs): @@ -33,15 +28,9 @@ def load_config(**kwargs): :param kwargs: A combination of all possible kwargs that can be passed to either load_kube_config or load_incluster_config functions. """ - if "kube_config_path" in kwargs.keys() or os.path.exists( - KUBE_CONFIG_DEFAULT_LOCATION - ): + if "kube_config_path" in kwargs.keys() or os.path.exists(KUBE_CONFIG_DEFAULT_LOCATION): load_kube_config(**kwargs) else: - print( - "kube_config_path not provided and default location ({0}) does not exist. " - "Using inCluster Config. This might not work.".format( - KUBE_CONFIG_DEFAULT_LOCATION - ) - ) + print("kube_config_path not provided and default location ({0}) does not exist. " + "Using inCluster Config. This might not work.".format(KUBE_CONFIG_DEFAULT_LOCATION)) load_incluster_config(**kwargs) diff --git a/watch/watch.py b/watch/watch.py index 7a143f7e..b432778e 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -32,15 +32,14 @@ PY2 = sys.version_info[0] == 2 if PY2: import httplib - HTTP_STATUS_GONE = httplib.GONE else: import http - HTTP_STATUS_GONE = http.HTTPStatus.GONE class SimpleNamespace: + def __init__(self, **kwargs): self.__dict__.update(kwargs) @@ -56,7 +55,7 @@ def iter_resp_lines(resp): prev = "" for seg in resp.read_chunked(decode_content=False): if isinstance(seg, bytes): - seg = seg.decode("utf8") + seg = seg.decode('utf8') seg = prev + seg lines = seg.split("\n") if not seg.endswith("\n"): @@ -70,6 +69,7 @@ def iter_resp_lines(resp): class Watch(object): + def __init__(self, return_type=None): self._raw_return_type = return_type self._stop = False @@ -84,31 +84,29 @@ def get_return_type(self, func): return self._raw_return_type return_type = _find_return_type(func) if return_type.endswith(TYPE_LIST_SUFFIX): - return return_type[: -len(TYPE_LIST_SUFFIX)] + return return_type[:-len(TYPE_LIST_SUFFIX)] return return_type def get_watch_argument_name(self, func): if PYDOC_FOLLOW_PARAM in pydoc.getdoc(func): - return "follow" + return 'follow' else: - return "watch" + return 'watch' def unmarshal_event(self, data, return_type): js = json.loads(data) - js["raw_object"] = js["object"] - if return_type and js["type"] != "ERROR": - obj = SimpleNamespace(data=json.dumps(js["raw_object"])) - js["object"] = self._api_client.deserialize(obj, return_type) - if hasattr(js["object"], "metadata"): - self.resource_version = js["object"].metadata.resource_version + js['raw_object'] = js['object'] + if return_type and js['type'] != 'ERROR': + obj = SimpleNamespace(data=json.dumps(js['raw_object'])) + js['object'] = self._api_client.deserialize(obj, return_type) + if hasattr(js['object'], 'metadata'): + self.resource_version = js['object'].metadata.resource_version # For custom objects that we don't have model defined, json # deserialization results in dictionary - elif ( - isinstance(js["object"], dict) - and "metadata" in js["object"] - and "resourceVersion" in js["object"]["metadata"] - ): - self.resource_version = js["object"]["metadata"]["resourceVersion"] + elif (isinstance(js['object'], dict) and 'metadata' in js['object'] + and 'resourceVersion' in js['object']['metadata']): + self.resource_version = js['object']['metadata'][ + 'resourceVersion'] return js def stream(self, func, *args, **kwargs): @@ -149,13 +147,13 @@ def stream(self, func, *args, **kwargs): return_type = self.get_return_type(func) watch_arg = self.get_watch_argument_name(func) kwargs[watch_arg] = True - kwargs["_preload_content"] = False - if "resource_version" in kwargs: - self.resource_version = kwargs["resource_version"] + kwargs['_preload_content'] = False + if 'resource_version' in kwargs: + self.resource_version = kwargs['resource_version'] # Do not attempt retries if user specifies a timeout. # We want to ensure we are returning within that timeout. - disable_retries = "timeout_seconds" in kwargs + disable_retries = ('timeout_seconds' in kwargs) retry_after_410 = False while True: resp = func(*args, **kwargs) @@ -165,23 +163,20 @@ def stream(self, func, *args, **kwargs): # return raw string when we are streaming log if watch_arg == "watch": event = self.unmarshal_event(line, return_type) - if isinstance(event, dict) and event["type"] == "ERROR": - obj = event["raw_object"] + if isinstance(event, dict) \ + and event['type'] == 'ERROR': + obj = event['raw_object'] # Current request expired, let's retry, (if enabled) # but only if we have not already retried. - if ( - not disable_retries - and not retry_after_410 - and obj["code"] == HTTP_STATUS_GONE - ): + if not disable_retries and not retry_after_410 and \ + obj['code'] == HTTP_STATUS_GONE: retry_after_410 = True break else: reason = "%s: %s" % ( - obj["reason"], obj["message"]) + obj['reason'], obj['message']) raise client.rest.ApiException( - status=obj["code"], reason=reason - ) + status=obj['code'], reason=reason) else: retry_after_410 = False yield event @@ -193,7 +188,7 @@ def stream(self, func, *args, **kwargs): resp.close() resp.release_conn() if self.resource_version is not None: - kwargs["resource_version"] = self.resource_version + kwargs['resource_version'] = self.resource_version else: self._stop = True From cf2f312fd06debceee9a06afe2eefccbd2649f1e Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Sun, 21 Mar 2021 23:59:43 -0400 Subject: [PATCH 106/141] Applied autopep8 properly this time. This should work --- config/__init__.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 204819eb..c7c68777 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -15,8 +15,12 @@ import os from .config_exception import ConfigException from .incluster_config import load_incluster_config -from .kube_config import (list_kube_config_contexts, load_kube_config, - load_kube_config_from_dict, new_client_from_config, KUBE_CONFIG_DEFAULT_LOCATION) +from .kube_config import ( + list_kube_config_contexts, + load_kube_config, + load_kube_config_from_dict, + new_client_from_config, + KUBE_CONFIG_DEFAULT_LOCATION) def load_config(**kwargs): @@ -28,9 +32,11 @@ def load_config(**kwargs): :param kwargs: A combination of all possible kwargs that can be passed to either load_kube_config or load_incluster_config functions. """ - if "kube_config_path" in kwargs.keys() or os.path.exists(KUBE_CONFIG_DEFAULT_LOCATION): + if "kube_config_path" in kwargs.keys() or os.path.exists( + KUBE_CONFIG_DEFAULT_LOCATION): load_kube_config(**kwargs) else: - print("kube_config_path not provided and default location ({0}) does not exist. " - "Using inCluster Config. This might not work.".format(KUBE_CONFIG_DEFAULT_LOCATION)) + print( + "kube_config_path not provided and default location ({0}) does not exist. " + "Using inCluster Config. This might not work.".format(KUBE_CONFIG_DEFAULT_LOCATION)) load_incluster_config(**kwargs) From b5aa2dd3718949a066cf1f01927ef4432f2e4dcc Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Mon, 22 Mar 2021 00:16:52 -0400 Subject: [PATCH 107/141] Address remarks from pycodestyle --- config/__init__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index c7c68777..607adc72 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -26,10 +26,13 @@ def load_config(**kwargs): """ Wrapper function to load the kube_config. - It will initially try to load_kube_config from provided path, then check if the KUBE_CONFIG_DEFAULT_LOCATION exists - If neither exists- it will fall back to load_incluster_config and inform the user accordingly. + It will initially try to load_kube_config from provided path, + then check if the KUBE_CONFIG_DEFAULT_LOCATION exists + If neither exists- it will fall back to load_incluster_config + and inform the user accordingly. - :param kwargs: A combination of all possible kwargs that can be passed to either load_kube_config or + :param kwargs: A combination of all possible kwargs that + can be passed to either load_kube_config or load_incluster_config functions. """ if "kube_config_path" in kwargs.keys() or os.path.exists( @@ -37,6 +40,8 @@ def load_config(**kwargs): load_kube_config(**kwargs) else: print( - "kube_config_path not provided and default location ({0}) does not exist. " - "Using inCluster Config. This might not work.".format(KUBE_CONFIG_DEFAULT_LOCATION)) + "kube_config_path not provided and " + "default location ({0}) does not exist. " + "Using inCluster Config. " + "This might not work.".format(KUBE_CONFIG_DEFAULT_LOCATION)) load_incluster_config(**kwargs) From 698299af9d3229d02624c4e6bb87e076bdcea000 Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Mon, 22 Mar 2021 00:22:04 -0400 Subject: [PATCH 108/141] isort --- config/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 607adc72..41702b96 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -13,14 +13,12 @@ # limitations under the License. import os + from .config_exception import ConfigException from .incluster_config import load_incluster_config -from .kube_config import ( - list_kube_config_contexts, - load_kube_config, - load_kube_config_from_dict, - new_client_from_config, - KUBE_CONFIG_DEFAULT_LOCATION) +from .kube_config import (KUBE_CONFIG_DEFAULT_LOCATION, + list_kube_config_contexts, load_kube_config, + load_kube_config_from_dict, new_client_from_config) def load_config(**kwargs): From 90399663f378b33227f723d3f0c1677965b6d96b Mon Sep 17 00:00:00 2001 From: Darren Hague Date: Thu, 8 Apr 2021 13:49:46 +0100 Subject: [PATCH 109/141] Fixes kubernetes-client/python issue 1047 "ResponseNotChunked from watch" In recent versions of K8S (>1.16?), when a `Watch.stream()` call uses a resource_version which is too old the resulting 410 error is wrapped in JSON and returned in a non-chunked 200 response. Using `resp.stream()` instead of `resp.read_chunked()` automatically handles the response being either chunked or non-chunked. --- watch/watch.py | 2 +- watch/watch_test.py | 44 +++++++++++++++++++++++++------------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/watch/watch.py b/watch/watch.py index b432778e..3bbb770d 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -53,7 +53,7 @@ def _find_return_type(func): def iter_resp_lines(resp): prev = "" - for seg in resp.read_chunked(decode_content=False): + for seg in resp.stream(amt=None, decode_content=False): if isinstance(seg, bytes): seg = seg.decode('utf8') seg = prev + seg diff --git a/watch/watch_test.py b/watch/watch_test.py index 32cf6334..cad72fd8 100644 --- a/watch/watch_test.py +++ b/watch/watch_test.py @@ -30,7 +30,7 @@ def test_watch_with_decode(self): fake_resp = Mock() fake_resp.close = Mock() fake_resp.release_conn = Mock() - fake_resp.read_chunked = Mock( + fake_resp.stream = Mock( return_value=[ '{"type": "ADDED", "object": {"metadata": {"name": "test1",' '"resourceVersion": "1"}, "spec": {}, "status": {}}}\n', @@ -63,7 +63,8 @@ def test_watch_with_decode(self): fake_api.get_namespaces.assert_called_once_with( _preload_content=False, watch=True) - fake_resp.read_chunked.assert_called_once_with(decode_content=False) + fake_resp.stream.assert_called_once_with( + amt=None, decode_content=False) fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() @@ -71,7 +72,7 @@ def test_watch_for_follow(self): fake_resp = Mock() fake_resp.close = Mock() fake_resp.release_conn = Mock() - fake_resp.read_chunked = Mock( + fake_resp.stream = Mock( return_value=[ 'log_line_1\n', 'log_line_2\n']) @@ -92,7 +93,8 @@ def test_watch_for_follow(self): fake_api.read_namespaced_pod_log.assert_called_once_with( _preload_content=False, follow=True) - fake_resp.read_chunked.assert_called_once_with(decode_content=False) + fake_resp.stream.assert_called_once_with( + amt=None, decode_content=False) fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() @@ -112,6 +114,7 @@ def test_watch_resource_version_set(self): '{"type": "ADDED", "object": {"metadata": {"name": "test3",' '"resourceVersion": "3"}, "spec": {}, "status": {}}}\n' ] + # return nothing on the first call and values on the second # this emulates a watch from a rv that returns nothing in the first k8s # watch reset and values later @@ -123,7 +126,7 @@ def get_values(*args, **kwargs): else: return values - fake_resp.read_chunked = Mock( + fake_resp.stream = Mock( side_effect=get_values) fake_api = Mock() @@ -170,7 +173,7 @@ def test_watch_stream_twice(self): fake_resp = Mock() fake_resp.close = Mock() fake_resp.release_conn = Mock() - fake_resp.read_chunked = Mock( + fake_resp.stream = Mock( return_value=['{"type": "ADDED", "object": 1}\n'] * 4) fake_api = Mock() @@ -186,8 +189,8 @@ def test_watch_stream_twice(self): self.assertEqual(count, 3) fake_api.get_namespaces.assert_called_once_with( _preload_content=False, watch=True) - fake_resp.read_chunked.assert_called_once_with( - decode_content=False) + fake_resp.stream.assert_called_once_with( + amt=None, decode_content=False) fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() @@ -197,7 +200,7 @@ def test_watch_stream_loop(self): fake_resp = Mock() fake_resp.close = Mock() fake_resp.release_conn = Mock() - fake_resp.read_chunked = Mock( + fake_resp.stream = Mock( return_value=['{"type": "ADDED", "object": 1}\n']) fake_api = Mock() @@ -219,7 +222,7 @@ def test_watch_stream_loop(self): self.assertEqual(count, 2) self.assertEqual(fake_api.get_namespaces.call_count, 2) - self.assertEqual(fake_resp.read_chunked.call_count, 2) + self.assertEqual(fake_resp.stream.call_count, 2) self.assertEqual(fake_resp.close.call_count, 2) self.assertEqual(fake_resp.release_conn.call_count, 2) @@ -256,7 +259,7 @@ def test_watch_with_exception(self): fake_resp = Mock() fake_resp.close = Mock() fake_resp.release_conn = Mock() - fake_resp.read_chunked = Mock(side_effect=KeyError('expected')) + fake_resp.stream = Mock(side_effect=KeyError('expected')) fake_api = Mock() fake_api.get_thing = Mock(return_value=fake_resp) @@ -271,7 +274,8 @@ def test_watch_with_exception(self): fake_api.get_thing.assert_called_once_with( _preload_content=False, watch=True) - fake_resp.read_chunked.assert_called_once_with(decode_content=False) + fake_resp.stream.assert_called_once_with( + amt=None, decode_content=False) fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() @@ -279,7 +283,7 @@ def test_watch_with_error_event(self): fake_resp = Mock() fake_resp.close = Mock() fake_resp.release_conn = Mock() - fake_resp.read_chunked = Mock( + fake_resp.stream = Mock( return_value=[ '{"type": "ERROR", "object": {"code": 410, ' '"reason": "Gone", "message": "error message"}}\n']) @@ -294,7 +298,8 @@ def test_watch_with_error_event(self): fake_api.get_thing.assert_called_once_with( _preload_content=False, watch=True) - fake_resp.read_chunked.assert_called_once_with(decode_content=False) + fake_resp.stream.assert_called_once_with( + amt=None, decode_content=False) fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() @@ -302,7 +307,7 @@ def test_watch_retries_on_error_event(self): fake_resp = Mock() fake_resp.close = Mock() fake_resp.release_conn = Mock() - fake_resp.read_chunked = Mock( + fake_resp.stream = Mock( return_value=[ '{"type": "ERROR", "object": {"code": 410, ' '"reason": "Gone", "message": "error message"}}\n']) @@ -320,8 +325,8 @@ def test_watch_retries_on_error_event(self): # Two calls should be expected during a retry fake_api.get_thing.assert_has_calls( [call(resource_version=0, _preload_content=False, watch=True)] * 2) - fake_resp.read_chunked.assert_has_calls( - [call(decode_content=False)] * 2) + fake_resp.stream.assert_has_calls( + [call(amt=None, decode_content=False)] * 2) assert fake_resp.close.call_count == 2 assert fake_resp.release_conn.call_count == 2 @@ -329,7 +334,7 @@ def test_watch_with_error_event_and_timeout_param(self): fake_resp = Mock() fake_resp.close = Mock() fake_resp.release_conn = Mock() - fake_resp.read_chunked = Mock( + fake_resp.stream = Mock( return_value=[ '{"type": "ERROR", "object": {"code": 410, ' '"reason": "Gone", "message": "error message"}}\n']) @@ -346,7 +351,8 @@ def test_watch_with_error_event_and_timeout_param(self): fake_api.get_thing.assert_called_once_with( _preload_content=False, watch=True, timeout_seconds=10) - fake_resp.read_chunked.assert_called_once_with(decode_content=False) + fake_resp.stream.assert_called_once_with( + amt=None, decode_content=False) fake_resp.close.assert_called_once() fake_resp.release_conn.assert_called_once() From 10ae4760b53a917116ae7525a7bbc94f35632cfb Mon Sep 17 00:00:00 2001 From: Yu Liao Date: Mon, 12 Apr 2021 17:17:42 -0700 Subject: [PATCH 110/141] quick fix of decoding error for BOOKMARK event --- watch/watch.py | 6 +++++- watch/watch_test.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/watch/watch.py b/watch/watch.py index 3bbb770d..71fd4591 100644 --- a/watch/watch.py +++ b/watch/watch.py @@ -96,7 +96,11 @@ def get_watch_argument_name(self, func): def unmarshal_event(self, data, return_type): js = json.loads(data) js['raw_object'] = js['object'] - if return_type and js['type'] != 'ERROR': + # BOOKMARK event is treated the same as ERROR for a quick fix of + # decoding exception + # TODO: make use of the resource_version in BOOKMARK event for more + # efficient WATCH + if return_type and js['type'] != 'ERROR' and js['type'] != 'BOOKMARK': obj = SimpleNamespace(data=json.dumps(js['raw_object'])) js['object'] = self._api_client.deserialize(obj, return_type) if hasattr(js['object'], 'metadata'): diff --git a/watch/watch_test.py b/watch/watch_test.py index cad72fd8..f87a4ea8 100644 --- a/watch/watch_test.py +++ b/watch/watch_test.py @@ -255,6 +255,19 @@ def test_unmarshal_with_custom_object(self): self.assertEqual("1", event['object']['metadata']['resourceVersion']) self.assertEqual("1", w.resource_version) + def test_unmarshal_with_bookmark(self): + w = Watch() + event = w.unmarshal_event( + '{"type":"BOOKMARK","object":{"kind":"Job","apiVersion":"batch/v1"' + ',"metadata":{"resourceVersion":"1"},"spec":{"template":{' + '"metadata":{},"spec":{"containers":null}}},"status":{}}}', + 'V1Job') + self.assertEqual("BOOKMARK", event['type']) + # Watch.resource_version is *not* updated, as BOOKMARK is treated the + # same as ERROR for a quick fix of decoding exception, + # resource_version in BOOKMARK is *not* used at all. + self.assertEqual(None, w.resource_version) + def test_watch_with_exception(self): fake_resp = Mock() fake_resp.close = Mock() From fc5b7302b161697ed6fbdf0c5aa85a119768255a Mon Sep 17 00:00:00 2001 From: JackYoon Date: Mon, 12 Apr 2021 18:57:34 +0800 Subject: [PATCH 111/141] load_kube_config_from_dict() support define custom temp files path --- config/kube_config.py | 45 +++++++++++++++++++++++++------------- config/kube_config_test.py | 23 +++++++++++++++++++ 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 61a261f6..584b8a41 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -60,7 +60,7 @@ def _cleanup_temp_files(): _temp_files = {} -def _create_temp_file_with_content(content): +def _create_temp_file_with_content(content, temp_file_path=None): if len(_temp_files) == 0: atexit.register(_cleanup_temp_files) # Because we may change context several times, try to remember files we @@ -68,7 +68,9 @@ def _create_temp_file_with_content(content): content_key = str(content) if content_key in _temp_files: return _temp_files[content_key] - _, name = tempfile.mkstemp() + if temp_file_path and not os.path.isdir(temp_file_path): + os.makedirs(name=temp_file_path) + _, name = tempfile.mkstemp(dir=temp_file_path) _temp_files[content_key] = name with open(name, 'wb') as fd: fd.write(content.encode() if isinstance(content, str) else content) @@ -91,12 +93,14 @@ class FileOrData(object): result in base64 encode of the file content after read.""" def __init__(self, obj, file_key_name, data_key_name=None, - file_base_path="", base64_file_content=True): + file_base_path="", base64_file_content=True, + temp_file_path=None): if not data_key_name: data_key_name = file_key_name + "-data" self._file = None self._data = None self._base64_file_content = base64_file_content + self._temp_file_path = temp_file_path if not obj: return if data_key_name in obj: @@ -116,9 +120,10 @@ def as_file(self): else: content = self._data self._file = _create_temp_file_with_content( - base64.standard_b64decode(content)) + base64.standard_b64decode(content), self._temp_file_path) else: - self._file = _create_temp_file_with_content(self._data) + self._file = _create_temp_file_with_content( + self._data, self._temp_file_path) if self._file and not os.path.isfile(self._file): raise ConfigException("File does not exist: %s" % self._file) return self._file @@ -182,7 +187,8 @@ class KubeConfigLoader(object): def __init__(self, config_dict, active_context=None, get_google_credentials=None, config_base_path="", - config_persister=None): + config_persister=None, + temp_file_path=None): if config_dict is None: raise ConfigException( @@ -199,6 +205,7 @@ def __init__(self, config_dict, active_context=None, self.set_active_context(active_context) self._config_base_path = config_base_path self._config_persister = config_persister + self._temp_file_path = temp_file_path def _refresh_credentials_with_cmd_path(): config = self._user['auth-provider']['config'] @@ -489,12 +496,14 @@ def _load_from_exec_plugin(self): status, None, data_key_name='clientCertificateData', file_base_path=base_path, - base64_file_content=False).as_file() + base64_file_content=False, + temp_file_path=self._temp_file_path).as_file() self.key_file = FileOrData( status, None, data_key_name='clientKeyData', file_base_path=base_path, - base64_file_content=False).as_file() + base64_file_content=False, + temp_file_path=self._temp_file_path).as_file() return True logging.error('exec: missing token or clientCertificateData field ' 'in plugin output') @@ -507,7 +516,8 @@ def _load_user_token(self): token = FileOrData( self._user, 'tokenFile', 'token', file_base_path=base_path, - base64_file_content=False).as_data() + base64_file_content=False, + temp_file_path=self._temp_file_path).as_data() if token: self.token = "Bearer %s" % token return True @@ -533,17 +543,20 @@ def _load_cluster_info(self): base_path = self._get_base_path(self._cluster.path) self.ssl_ca_cert = FileOrData( self._cluster, 'certificate-authority', - file_base_path=base_path).as_file() + file_base_path=base_path, + temp_file_path=self._temp_file_path).as_file() if 'cert_file' not in self.__dict__: # cert_file could have been provided by # _load_from_exec_plugin; only load from the _user # section if we need it. self.cert_file = FileOrData( self._user, 'client-certificate', - file_base_path=base_path).as_file() + file_base_path=base_path, + temp_file_path=self._temp_file_path).as_file() self.key_file = FileOrData( self._user, 'client-key', - file_base_path=base_path).as_file() + file_base_path=base_path, + temp_file_path=self._temp_file_path).as_file() if 'insecure-skip-tls-verify' in self._cluster: self.verify_ssl = not self._cluster['insecure-skip-tls-verify'] @@ -811,7 +824,8 @@ def load_kube_config(config_file=None, context=None, def load_kube_config_from_dict(config_dict, context=None, client_configuration=None, - persist_config=True): + persist_config=True, + temp_file_path=None): """Loads authentication and cluster information from config_dict file and stores them in kubernetes.client.configuration. @@ -822,8 +836,8 @@ def load_kube_config_from_dict(config_dict, context=None, set configs to. :param persist_config: If True, config file will be updated when changed (e.g GCP token refresh). + :param temp_file_path: store temp files path. """ - if config_dict is None: raise ConfigException( 'Invalid kube-config dict. ' @@ -831,7 +845,8 @@ def load_kube_config_from_dict(config_dict, context=None, loader = _get_kube_config_loader( config_dict=config_dict, active_context=context, - persist_config=persist_config) + persist_config=persist_config, + temp_file_path=temp_file_path) if client_configuration is None: config = type.__call__(Configuration) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index a82ef40e..c33ffed7 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1290,6 +1290,29 @@ def test_load_kube_config_from_dict(self): client_configuration=actual) self.assertEqual(expected, actual) + def test_load_kube_config_from_dict_with_temp_file_path(self): + expected = FakeConfig( + host=TEST_SSL_HOST, + token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, + cert_file=self._create_temp_file(TEST_CLIENT_CERT), + key_file=self._create_temp_file(TEST_CLIENT_KEY), + ssl_ca_cert=self._create_temp_file(TEST_CERTIFICATE_AUTH), + verify_ssl=True + ) + actual = FakeConfig() + tmp_path = os.path.join( + os.path.dirname( + os.path.dirname( + os.path.abspath(__file__))), + 'tmp_file_path_test') + load_kube_config_from_dict(config_dict=self.TEST_KUBE_CONFIG, + context="ssl", + client_configuration=actual, + temp_file_path=tmp_path) + self.assertFalse(True if not os.listdir(tmp_path) else False) + self.assertEqual(expected, actual) + _cleanup_temp_files + def test_load_kube_config_from_empty_file_like_object(self): config_file_like_object = io.StringIO() self.assertRaises( From 96bb22fac5f65b2ea7696a0d48f3f1aa42f9457a Mon Sep 17 00:00:00 2001 From: Haowei Cai Date: Tue, 20 Apr 2021 17:06:52 -0700 Subject: [PATCH 112/141] add PR template --- .github/PULL_REQUEST_TEMPLATE.md | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..f6af35b4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,72 @@ + + +#### What type of PR is this? + + + +#### What this PR does / why we need it: + +#### Which issue(s) this PR fixes: + +Fixes # + +#### Special notes for your reviewer: + +#### Does this PR introduce a user-facing change? + +```release-note + +``` + +#### Additional documentation e.g., KEPs (Kubernetes Enhancement Proposals), usage docs, etc.: + + +```docs + +``` From 90e16c698e189c7a674868803ea1e1ff70452d10 Mon Sep 17 00:00:00 2001 From: jonasdlindner Date: Fri, 30 Apr 2021 23:53:25 +0200 Subject: [PATCH 113/141] Rename Method _websocket_reqeust to _websocket_request --- stream/stream.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stream/stream.py b/stream/stream.py index 57bac758..115a899b 100644 --- a/stream/stream.py +++ b/stream/stream.py @@ -17,7 +17,7 @@ from . import ws_client -def _websocket_reqeust(websocket_request, force_kwargs, api_method, *args, **kwargs): +def _websocket_request(websocket_request, force_kwargs, api_method, *args, **kwargs): """Override the ApiClient.request method with an alternative websocket based method and call the supplied Kubernetes API method with that in place.""" if force_kwargs: @@ -37,5 +37,5 @@ def _websocket_reqeust(websocket_request, force_kwargs, api_method, *args, **kwa api_client.request = prev_request -stream = functools.partial(_websocket_reqeust, ws_client.websocket_call, None) -portforward = functools.partial(_websocket_reqeust, ws_client.portforward_call, {'_preload_content':False}) +stream = functools.partial(_websocket_request, ws_client.websocket_call, None) +portforward = functools.partial(_websocket_request, ws_client.portforward_call, {'_preload_content':False}) From bde3935f2698b0145b8c1cf50fae0f67215e7c1f Mon Sep 17 00:00:00 2001 From: Yash Kumar Singh Date: Tue, 27 Apr 2021 12:05:04 +0530 Subject: [PATCH 114/141] =?UTF-8?q?Support=20customizing=20=E2=80=9CAccept?= =?UTF-8?q?=E2=80=9D=20header=20and=20added=20a=20testcase=20to=20test=20c?= =?UTF-8?q?ustom=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dynamic/client.py | 13 ++++++++----- dynamic/test_client.py | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/dynamic/client.py b/dynamic/client.py index 7b82b3d6..f6926508 100644 --- a/dynamic/client.py +++ b/dynamic/client.py @@ -219,11 +219,14 @@ def request(self, method, path, body=None, **params): header_params = params.get('header_params', {}) form_params = [] local_var_files = {} - # HTTP header `Accept` - header_params['Accept'] = self.client.select_header_accept([ - 'application/json', - 'application/yaml', - ]) + + # Checking Accept header. + new_header_params = dict((key.lower(), value) for key, value in header_params.items()) + if not 'accept' in new_header_params: + header_params['Accept'] = self.client.select_header_accept([ + 'application/json', + 'application/yaml', + ]) # HTTP header `Content-Type` if params.get('content_type'): diff --git a/dynamic/test_client.py b/dynamic/test_client.py index b68e081f..54e41bb4 100644 --- a/dynamic/test_client.py +++ b/dynamic/test_client.py @@ -359,7 +359,7 @@ def test_configmap_apis(self): resp = api.get(namespace='default', pretty=True, label_selector="e2e-test=true") self.assertEqual([], resp.items) - + def test_node_apis(self): client = DynamicClient(api_client.ApiClient(configuration=self.config)) api = client.resources.get(api_version='v1', kind='Node') @@ -367,3 +367,19 @@ def test_node_apis(self): for item in api.get().items: node = api.get(name=item.metadata.name) self.assertTrue(len(dict(node.metadata.labels)) > 0) + + # test_node_apis_partial_object_metadata lists all nodes in the cluster, but only retrieves object metadata + def test_node_apis_partial_object_metadata(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + api = client.resources.get(api_version='v1', kind='Node') + + params = {'header_params': {'Accept': 'application/json;as=PartialObjectMetadataList;v=v1;g=meta.k8s.io'}} + resp = api.get(**params) + self.assertEqual('PartialObjectMetadataList', resp.kind) + self.assertEqual('meta.k8s.io/v1', resp.apiVersion) + + params = {'header_params': {'aCcePt': 'application/json;as=PartialObjectMetadataList;v=v1;g=meta.k8s.io'}} + resp = api.get(**params) + self.assertEqual('PartialObjectMetadataList', resp.kind) + self.assertEqual('meta.k8s.io/v1', resp.apiVersion) + From 711d4ab880d66b7c34f5dae5a5379d05d51d26fe Mon Sep 17 00:00:00 2001 From: Priyanka Saggu Date: Fri, 14 May 2021 22:51:52 +0530 Subject: [PATCH 115/141] drop python2 support - remove python2 from the .travis.yaml file - remove python2 from the tox.ini file - remove `-y` flag from `isort` command in `update-pycodestle.sh` script - add update-pycodestyle, coverage & codecov tests for python3 Signed-off-by: Priyanka Saggu --- .travis.yml | 10 +++------- tox.ini | 4 ++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index b44ec90a..86a1bfa2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,16 +18,12 @@ jobs: script: ./hack/verify-boilerplate.sh python: 3.7 - stage: test - python: 2.7 - env: TOXENV=py27 - - python: 2.7 - env: TOXENV=py27-functional - - python: 2.7 + python: 3.9 env: TOXENV=update-pycodestyle + - python: 3.9 + env: TOXENV=coverage,codecov - python: 3.7 env: TOXENV=docs - - python: 2.7 - env: TOXENV=coverage,codecov - python: 3.5 env: TOXENV=py35 - python: 3.5 diff --git a/tox.ini b/tox.ini index 71c4d2d8..37a188f1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] skipsdist = True envlist = - py27, py3{5,6,7,8,9} - py27-functional, py3{5,6,7,8,9}-functional + py3{5,6,7,8,9} + py3{5,6,7,8,9}-functional [testenv] passenv = TOXENV CI TRAVIS TRAVIS_* From 6d1c8d3713057e87d973d853b36373c06901d092 Mon Sep 17 00:00:00 2001 From: Moshe Shitrit Date: Sat, 19 Jun 2021 17:42:37 +0300 Subject: [PATCH 116/141] Apply suggestion --- config/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/__init__.py b/config/__init__.py index 41702b96..76297f81 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -26,7 +26,7 @@ def load_config(**kwargs): Wrapper function to load the kube_config. It will initially try to load_kube_config from provided path, then check if the KUBE_CONFIG_DEFAULT_LOCATION exists - If neither exists- it will fall back to load_incluster_config + If neither exists, it will fall back to load_incluster_config and inform the user accordingly. :param kwargs: A combination of all possible kwargs that From dca0ca6df23098b63a347e12ceb0dca028a20572 Mon Sep 17 00:00:00 2001 From: David Otto Date: Wed, 7 Jul 2021 12:54:38 +0200 Subject: [PATCH 117/141] Fix load_config: expand ~ --- config/kube_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 584b8a41..040234d9 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -45,7 +45,7 @@ pass EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) -KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') +KUBE_CONFIG_DEFAULT_LOCATION = os.path.expanduser(os.environ.get('KUBECONFIG', '~/.kube/config')) ENV_KUBECONFIG_PATH_SEPARATOR = ';' if platform.system() == 'Windows' else ':' _temp_files = {} From e2ba3fb9fc2cd9dfdb873ab5d48255a1e7c2d26a Mon Sep 17 00:00:00 2001 From: David Otto Date: Mon, 12 Jul 2021 11:21:13 +0200 Subject: [PATCH 118/141] do expanduser in load_config --- config/__init__.py | 5 ++--- config/kube_config.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index 76297f81..e1bf7f57 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os +from os.path import exists, expanduser from .config_exception import ConfigException from .incluster_config import load_incluster_config @@ -33,8 +33,7 @@ def load_config(**kwargs): can be passed to either load_kube_config or load_incluster_config functions. """ - if "kube_config_path" in kwargs.keys() or os.path.exists( - KUBE_CONFIG_DEFAULT_LOCATION): + if "kube_config_path" in kwargs.keys() or exists(expanduser(KUBE_CONFIG_DEFAULT_LOCATION)): load_kube_config(**kwargs) else: print( diff --git a/config/kube_config.py b/config/kube_config.py index 040234d9..584b8a41 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -45,7 +45,7 @@ pass EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5) -KUBE_CONFIG_DEFAULT_LOCATION = os.path.expanduser(os.environ.get('KUBECONFIG', '~/.kube/config')) +KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config') ENV_KUBECONFIG_PATH_SEPARATOR = ';' if platform.system() == 'Windows' else ':' _temp_files = {} From 6f9e3327a8fbdb791a654afffe94840081390189 Mon Sep 17 00:00:00 2001 From: Andrei Marin Date: Sun, 27 Jun 2021 18:26:50 +0300 Subject: [PATCH 119/141] Fix replication controller pods delete in tests --- dynamic/test_client.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/dynamic/test_client.py b/dynamic/test_client.py index 54e41bb4..78be0cc4 100644 --- a/dynamic/test_client.py +++ b/dynamic/test_client.py @@ -318,8 +318,10 @@ def test_replication_controller_apis(self): self.assertEqual(name, resp.metadata.name) self.assertEqual(2, resp.spec.replicas) - resp = api.delete( - name=name, body={}, namespace='default') + api.delete( + name=name, + namespace='default', + propagation_policy='Background') def test_configmap_apis(self): client = DynamicClient(api_client.ApiClient(configuration=self.config)) @@ -357,9 +359,12 @@ def test_configmap_apis(self): resp = api.delete( name=name, body={}, namespace='default') - resp = api.get(namespace='default', pretty=True, label_selector="e2e-test=true") + resp = api.get( + namespace='default', + pretty=True, + label_selector="e2e-test=true") self.assertEqual([], resp.items) - + def test_node_apis(self): client = DynamicClient(api_client.ApiClient(configuration=self.config)) api = client.resources.get(api_version='v1', kind='Node') @@ -367,19 +372,23 @@ def test_node_apis(self): for item in api.get().items: node = api.get(name=item.metadata.name) self.assertTrue(len(dict(node.metadata.labels)) > 0) - - # test_node_apis_partial_object_metadata lists all nodes in the cluster, but only retrieves object metadata + + # test_node_apis_partial_object_metadata lists all nodes in the cluster, + # but only retrieves object metadata def test_node_apis_partial_object_metadata(self): client = DynamicClient(api_client.ApiClient(configuration=self.config)) api = client.resources.get(api_version='v1', kind='Node') - - params = {'header_params': {'Accept': 'application/json;as=PartialObjectMetadataList;v=v1;g=meta.k8s.io'}} + + params = { + 'header_params': { + 'Accept': 'application/json;as=PartialObjectMetadataList;v=v1;g=meta.k8s.io'}} resp = api.get(**params) self.assertEqual('PartialObjectMetadataList', resp.kind) self.assertEqual('meta.k8s.io/v1', resp.apiVersion) - params = {'header_params': {'aCcePt': 'application/json;as=PartialObjectMetadataList;v=v1;g=meta.k8s.io'}} + params = { + 'header_params': { + 'aCcePt': 'application/json;as=PartialObjectMetadataList;v=v1;g=meta.k8s.io'}} resp = api.get(**params) self.assertEqual('PartialObjectMetadataList', resp.kind) self.assertEqual('meta.k8s.io/v1', resp.apiVersion) - From cbb71698d775c910d6b18432f48a06dd35449e76 Mon Sep 17 00:00:00 2001 From: Mike Graves Date: Mon, 26 Jul 2021 13:23:57 -0400 Subject: [PATCH 120/141] Add support for dryRun parameter --- dynamic/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dynamic/client.py b/dynamic/client.py index f6926508..72b38113 100644 --- a/dynamic/client.py +++ b/dynamic/client.py @@ -215,6 +215,8 @@ def request(self, method, path, body=None, **params): query_params.append(('propagationPolicy', params['propagation_policy'])) if params.get('orphan_dependents') is not None: query_params.append(('orphanDependents', params['orphan_dependents'])) + if params.get('dry_run') is not None: + query_params.append(('dryRun', params['dry_run'])) header_params = params.get('header_params', {}) form_params = [] From 59ba58b49469bd63b69650d7a0ad0429bc08a0a3 Mon Sep 17 00:00:00 2001 From: Hedi Nasr Date: Wed, 23 Jun 2021 11:27:40 +0200 Subject: [PATCH 121/141] Add the ability to stop the watcher gracefully. --- dynamic/client.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/dynamic/client.py b/dynamic/client.py index f6926508..43f6bf40 100644 --- a/dynamic/client.py +++ b/dynamic/client.py @@ -144,7 +144,7 @@ def patch(self, resource, body=None, name=None, namespace=None, **kwargs): return self.request('patch', path, body=body, content_type=content_type, **kwargs) - def watch(self, resource, namespace=None, name=None, label_selector=None, field_selector=None, resource_version=None, timeout=None): + def watch(self, resource, namespace=None, name=None, label_selector=None, field_selector=None, resource_version=None, timeout=None, watcher=None): """ Stream events for a resource from the Kubernetes API @@ -156,6 +156,7 @@ def watch(self, resource, namespace=None, name=None, label_selector=None, field_ :param resource_version: The version with which to filter results. Only events with a resource_version greater than this value will be returned :param timeout: The amount of time in seconds to wait before terminating the stream + :param watcher: The Watcher object that will be used to stream the resource :return: Event object with these keys: 'type': The type of event such as "ADDED", "DELETED", etc. @@ -164,13 +165,17 @@ def watch(self, resource, namespace=None, name=None, label_selector=None, field_ Example: client = DynamicClient(k8s_client) + watcher = watch.Watch() v1_pods = client.resources.get(api_version='v1', kind='Pod') - for e in v1_pods.watch(resource_version=0, namespace=default, timeout=5): + for e in v1_pods.watch(resource_version=0, namespace=default, timeout=5, watcher=watcher): print(e['type']) print(e['object'].metadata) + # If you want to gracefully stop the stream watcher + watcher.stop() """ - watcher = watch.Watch() + if not watcher: watcher = watch.Watch() + for event in watcher.stream( resource.get, namespace=namespace, From b0b0ddeedc0338df0aa36c0e16d277ab8165ad1c Mon Sep 17 00:00:00 2001 From: Fabian von Feilitzsch Date: Thu, 29 Jul 2021 16:56:44 -0400 Subject: [PATCH 122/141] Add fabianvf to reviewers --- OWNERS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/OWNERS b/OWNERS index c331e688..47444bf9 100644 --- a/OWNERS +++ b/OWNERS @@ -5,4 +5,5 @@ approvers: - roycaihw emeritus_approvers: - mbohlool - +reviewers: + - fabianvf From 66a45cd081b17041afd62712c5c213d310fa30b3 Mon Sep 17 00:00:00 2001 From: piglei Date: Sun, 22 Aug 2021 11:20:59 +0800 Subject: [PATCH 123/141] Make duck-typing in serialize_body method more restrictive --- dynamic/client.py | 7 ++++++- dynamic/test_client.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/dynamic/client.py b/dynamic/client.py index 9d32770b..353a481b 100644 --- a/dynamic/client.py +++ b/dynamic/client.py @@ -98,7 +98,12 @@ def ensure_namespace(self, resource, namespace, body): return namespace def serialize_body(self, body): - if hasattr(body, 'to_dict'): + """Serialize body to raw dict so apiserver can handle it + + :param body: kubernetes resource body, current support: Union[Dict, ResourceInstance] + """ + # This should match any `ResourceInstance` instances + if callable(getattr(body, 'to_dict', None)): return body.to_dict() return body or {} diff --git a/dynamic/test_client.py b/dynamic/test_client.py index 78be0cc4..ab1df93f 100644 --- a/dynamic/test_client.py +++ b/dynamic/test_client.py @@ -20,6 +20,7 @@ from kubernetes.client import api_client from . import DynamicClient +from .resource import ResourceInstance, ResourceField from .exceptions import ResourceNotFoundError @@ -392,3 +393,32 @@ def test_node_apis_partial_object_metadata(self): resp = api.get(**params) self.assertEqual('PartialObjectMetadataList', resp.kind) self.assertEqual('meta.k8s.io/v1', resp.apiVersion) + + +class TestDynamicClientSerialization(unittest.TestCase): + + @classmethod + def setUpClass(cls): + config = base.get_e2e_configuration() + cls.client = DynamicClient(api_client.ApiClient(configuration=config)) + cls.pod_manifest = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': {'name': 'foo-pod'}, + 'spec': {'containers': [{'name': "main", 'image': "busybox"}]}, + } + + def test_dict_type(self): + self.assertEqual(self.client.serialize_body(self.pod_manifest), self.pod_manifest) + + def test_resource_instance_type(self): + inst = ResourceInstance(self.client, self.pod_manifest) + self.assertEqual(self.client.serialize_body(inst), self.pod_manifest) + + def test_resource_field(self): + """`ResourceField` is a special type which overwrites `__getattr__` method to return `None` + when a non-existent attribute was accessed. which means it can pass any `hasattr(...)` tests. + """ + res = ResourceField(foo='bar') + # method will return original object when it doesn't know how to proceed + self.assertEqual(self.client.serialize_body(res), res) From 70b78cd8488068c014b6d762a0c8d358273865b4 Mon Sep 17 00:00:00 2001 From: Eric Menendez Date: Fri, 27 Aug 2021 15:25:07 -0600 Subject: [PATCH 124/141] Refresh exec-based API credentials when they expire This is a fix for kubernetes-client/python#741. As described in kubernetes-client/python#741, some of the authentication schemes supported by Kubernetes require updating the client's credentials from time to time. The Kubernetes Python client currently does not support this, except for when using the `gcp` auth scheme. This is because the OpenAPI-generated client code does not generally expect credentials to change after the client is configured. However, in OpenAPITools/openapi-generator#3594, the OpenAPI generator added a (undocumented) hook on the `Configuration` object which provides a method for the client credentials to be refreshed as needed. Now that this hook exists, the `load_kube_config()` function, used by the Kubernetes API to set up the `Configuration` object from the client's local k8s config, just needs to be updated to take advantage of this hook. This patch does this for `exec`-based authentication, which should resolve kubernetes-client/python#741. Also, as noted above, `load_kube_config()` already has a special-case monkeypatch to refresh GCP tokens. I presume this functionality was added before the OpenAPI generator added support for the refresh hook. This patch also refactors the GCP token refreshing code to use the new hook instead of the monkeypatch. Tests are also updated. --- config/kube_config.py | 38 +++++++--------- config/kube_config_test.py | 91 +++++++++++++++++++++++++------------- 2 files changed, 76 insertions(+), 53 deletions(-) diff --git a/config/kube_config.py b/config/kube_config.py index 584b8a41..f295dbcd 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -359,6 +359,8 @@ def _load_gcp_token(self, provider): self._refresh_gcp_token() self.token = "Bearer %s" % provider['config']['access-token'] + if 'expiry' in provider['config']: + self.expiry = parse_rfc3339(provider['config']['expiry']) return self.token def _refresh_gcp_token(self): @@ -483,8 +485,7 @@ def _load_from_exec_plugin(self): status = ExecProvider(self._user['exec']).run() if 'token' in status: self.token = "Bearer %s" % status['token'] - return True - if 'clientCertificateData' in status: + elif 'clientCertificateData' in status: # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#input-and-output-formats # Plugin has provided certificates instead of a token. if 'clientKeyData' not in status: @@ -504,10 +505,13 @@ def _load_from_exec_plugin(self): file_base_path=base_path, base64_file_content=False, temp_file_path=self._temp_file_path).as_file() - return True - logging.error('exec: missing token or clientCertificateData field ' - 'in plugin output') - return None + else: + logging.error('exec: missing token or clientCertificateData ' + 'field in plugin output') + return None + if 'expirationTimestamp' in status: + self.expiry = parse_rfc3339(status['expirationTimestamp']) + return True except Exception as e: logging.error(str(e)) @@ -560,25 +564,15 @@ def _load_cluster_info(self): if 'insecure-skip-tls-verify' in self._cluster: self.verify_ssl = not self._cluster['insecure-skip-tls-verify'] - def _using_gcp_auth_provider(self): - return self._user and \ - 'auth-provider' in self._user and \ - 'name' in self._user['auth-provider'] and \ - self._user['auth-provider']['name'] == 'gcp' - def _set_config(self, client_configuration): - if self._using_gcp_auth_provider(): - # GCP auth tokens must be refreshed regularly, but swagger expects - # a constant token. Replace the swagger-generated client config's - # get_api_key_with_prefix method with our own to allow automatic - # token refresh. - def _gcp_get_api_key(*args): - return self._load_gcp_token(self._user['auth-provider']) - client_configuration.get_api_key_with_prefix = _gcp_get_api_key if 'token' in self.__dict__: - # Note: this line runs for GCP auth tokens as well, but this entry - # will not be updated upon GCP token refresh. client_configuration.api_key['authorization'] = self.token + + def _refresh_api_key(client_configuration): + if ('expiry' in self.__dict__ and _is_expired(self.expiry)): + self._load_authentication() + self._set_config(client_configuration) + client_configuration.refresh_api_key_hook = _refresh_api_key # copy these keys directly from self to configuration object keys = ['host', 'ssl_ca_cert', 'cert_file', 'key_file', 'verify_ssl'] for key in keys: diff --git a/config/kube_config_test.py b/config/kube_config_test.py index c33ffed7..8151f948 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -29,7 +29,7 @@ from kubernetes.client import Configuration from .config_exception import ConfigException -from .dateutil import parse_rfc3339 +from .dateutil import format_rfc3339, parse_rfc3339 from .kube_config import (ENV_KUBECONFIG_PATH_SEPARATOR, CommandTokenSource, ConfigNode, FileOrData, KubeConfigLoader, KubeConfigMerger, _cleanup_temp_files, @@ -346,9 +346,12 @@ def test_get_with_name_on_duplicate_name(self): class FakeConfig: FILE_KEYS = ["ssl_ca_cert", "key_file", "cert_file"] + IGNORE_KEYS = ["refresh_api_key_hook"] def __init__(self, token=None, **kwargs): self.api_key = {} + # Provided by the OpenAPI-generated Configuration class + self.refresh_api_key_hook = None if token: self.api_key['authorization'] = token @@ -358,6 +361,8 @@ def __eq__(self, other): if len(self.__dict__) != len(other.__dict__): return for k, v in self.__dict__.items(): + if k in self.IGNORE_KEYS: + continue if k not in other.__dict__: return if k in self.FILE_KEYS: @@ -956,17 +961,15 @@ def test_load_user_token(self): def test_gcp_no_refresh(self): fake_config = FakeConfig() - # swagger-generated config has this, but FakeConfig does not. - self.assertFalse(hasattr(fake_config, 'get_api_key_with_prefix')) + self.assertIsNone(fake_config.refresh_api_key_hook) KubeConfigLoader( config_dict=self.TEST_KUBE_CONFIG, active_context="gcp", get_google_credentials=lambda: _raise_exception( "SHOULD NOT BE CALLED")).load_and_set(fake_config) # Should now be populated with a gcp token fetcher. - self.assertIsNotNone(fake_config.get_api_key_with_prefix) + self.assertIsNotNone(fake_config.refresh_api_key_hook) self.assertEqual(TEST_HOST, fake_config.host) - # For backwards compatibility, authorization field should still be set. self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, fake_config.api_key['authorization']) @@ -997,7 +1000,7 @@ def cred(): return None self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64, loader.token) - def test_gcp_get_api_key_with_prefix(self): + def test_gcp_refresh_api_key_hook(self): class cred_old: token = TEST_DATA_BASE64 expiry = DATETIME_EXPIRY_PAST @@ -1015,15 +1018,13 @@ class cred_new: get_google_credentials=_get_google_credentials) loader.load_and_set(fake_config) original_expiry = _get_expiry(loader, "expired_gcp_refresh") - # Call GCP token fetcher. - token = fake_config.get_api_key_with_prefix() + # Refresh the GCP token. + fake_config.refresh_api_key_hook(fake_config) new_expiry = _get_expiry(loader, "expired_gcp_refresh") self.assertTrue(new_expiry > original_expiry) self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64, loader.token) - self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64, - token) def test_oidc_no_refresh(self): loader = KubeConfigLoader( @@ -1383,6 +1384,38 @@ def test_user_exec_auth(self, mock): active_context="exec_cred_user").load_and_set(actual) self.assertEqual(expected, actual) + @mock.patch('kubernetes.config.kube_config.ExecProvider.run') + def test_user_exec_auth_with_expiry(self, mock): + expired_token = "expired" + current_token = "current" + mock.side_effect = [ + { + "token": expired_token, + "expirationTimestamp": format_rfc3339(DATETIME_EXPIRY_PAST) + }, + { + "token": current_token, + "expirationTimestamp": format_rfc3339(DATETIME_EXPIRY_FUTURE) + } + ] + + fake_config = FakeConfig() + self.assertIsNone(fake_config.refresh_api_key_hook) + + KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="exec_cred_user").load_and_set(fake_config) + # The kube config should use the first token returned from the + # exec provider. + self.assertEqual(fake_config.api_key["authorization"], + BEARER_TOKEN_FORMAT % expired_token) + # Should now be populated with a method to refresh expired tokens. + self.assertIsNotNone(fake_config.refresh_api_key_hook) + # Refresh the token; the kube config should be updated. + fake_config.refresh_api_key_hook(fake_config) + self.assertEqual(fake_config.api_key["authorization"], + BEARER_TOKEN_FORMAT % current_token) + @mock.patch('kubernetes.config.kube_config.ExecProvider.run') def test_user_exec_auth_certificates(self, mock): mock.return_value = { @@ -1412,7 +1445,6 @@ def test_user_cmd_path(self): KubeConfigLoader( config_dict=self.TEST_KUBE_CONFIG, active_context="contexttestcmdpath").load_and_set(actual) - del actual.get_api_key_with_prefix self.assertEqual(expected, actual) def test_user_cmd_path_empty(self): @@ -1490,31 +1522,28 @@ def test__get_kube_config_loader_dict_no_persist(self): class TestKubernetesClientConfiguration(BaseTestCase): # Verifies properties of kubernetes.client.Configuration. # These tests guard against changes to the upstream configuration class, - # since GCP authorization overrides get_api_key_with_prefix to refresh its - # token regularly. + # since GCP and Exec authorization use refresh_api_key_hook to refresh + # their tokens regularly. - def test_get_api_key_with_prefix_exists(self): - self.assertTrue(hasattr(Configuration, 'get_api_key_with_prefix')) + def test_refresh_api_key_hook_exists(self): + self.assertTrue(hasattr(Configuration(), 'refresh_api_key_hook')) - def test_get_api_key_with_prefix_returns_token(self): - expected_token = 'expected_token' - config = Configuration() - config.api_key['authorization'] = expected_token - self.assertEqual(expected_token, - config.get_api_key_with_prefix('authorization')) - - def test_auth_settings_calls_get_api_key_with_prefix(self): + def test_get_api_key_calls_refresh_api_key_hook(self): + identifier = 'authorization' expected_token = 'expected_token' old_token = 'old_token' + config = Configuration( + api_key={identifier: old_token}, + api_key_prefix={identifier: 'Bearer'} + ) + + def refresh_api_key_hook(client_config): + self.assertEqual(client_config, config) + client_config.api_key[identifier] = expected_token + config.refresh_api_key_hook = refresh_api_key_hook - def fake_get_api_key_with_prefix(identifier): - self.assertEqual('authorization', identifier) - return expected_token - config = Configuration() - config.api_key['authorization'] = old_token - config.get_api_key_with_prefix = fake_get_api_key_with_prefix - self.assertEqual(expected_token, - config.auth_settings()['BearerToken']['value']) + self.assertEqual('Bearer ' + expected_token, + config.get_api_key_with_prefix(identifier)) class TestKubeConfigMerger(BaseTestCase): From bd944a58a31f878c5bf4964f458d53512df2ece3 Mon Sep 17 00:00:00 2001 From: jamesgetx Date: Fri, 3 Sep 2021 17:30:56 +0800 Subject: [PATCH 125/141] fix: field extra_args recursive growth caused by Resource and Subresource to_dict method when cache with CacheDecoder --- dynamic/resource.py | 18 ++++++++++-------- dynamic/test_discovery.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/dynamic/resource.py b/dynamic/resource.py index c83ae9fd..6dac1d87 100644 --- a/dynamic/resource.py +++ b/dynamic/resource.py @@ -48,7 +48,7 @@ def __init__(self, prefix=None, group=None, api_version=None, kind=None, self.extra_args = kwargs def to_dict(self): - return { + d = { '_type': 'Resource', 'prefix': self.prefix, 'group': self.group, @@ -58,12 +58,13 @@ def to_dict(self): 'verbs': self.verbs, 'name': self.name, 'preferred': self.preferred, - 'singular_name': self.singular_name, - 'short_names': self.short_names, + 'singularName': self.singular_name, + 'shortNames': self.short_names, 'categories': self.categories, 'subresources': {k: sr.to_dict() for k, sr in self.subresources.items()}, - 'extra_args': self.extra_args, } + d.update(self.extra_args) + return d @property def group_version(self): @@ -236,7 +237,7 @@ def __init__(self, parent, **kwargs): self.api_version = parent.api_version self.kind = kwargs.pop('kind') self.name = kwargs.pop('name') - self.subresource = self.name.split('/')[1] + self.subresource = kwargs.pop('subresource', None) or self.name.split('/')[1] self.namespaced = kwargs.pop('namespaced', False) self.verbs = kwargs.pop('verbs', None) self.extra_args = kwargs @@ -262,14 +263,15 @@ def __getattr__(self, name): return partial(getattr(self.parent.client, name), self) def to_dict(self): - return { + d = { 'kind': self.kind, 'name': self.name, 'subresource': self.subresource, 'namespaced': self.namespaced, - 'verbs': self.verbs, - 'extra_args': self.extra_args, + 'verbs': self.verbs } + d.update(self.extra_args) + return d class ResourceInstance(object): diff --git a/dynamic/test_discovery.py b/dynamic/test_discovery.py index 4897f244..639ccdd3 100644 --- a/dynamic/test_discovery.py +++ b/dynamic/test_discovery.py @@ -38,3 +38,24 @@ def test_init_cache_from_file(self): # test no Discoverer._write_cache called self.assertTrue(mtime1 == mtime2) + + def test_cache_decoder_resource_and_subresource(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + # first invalidate cache + client.resources.invalidate_cache() + + # do Discoverer.__init__ + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + # the resources of client will use _cache['resources'] in memory + deploy1 = client.resources.get(kind='Deployment') + + # do Discoverer.__init__ + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + # the resources of client will use _cache['resources'] decode from cache file + deploy2 = client.resources.get(kind='Deployment') + + # test Resource is the same + self.assertTrue(deploy1 == deploy2) + + # test Subresource is the same + self.assertTrue(deploy1.status == deploy2.status) From c040d87bd847d5afe480dcc2d39ad46cb6234cc3 Mon Sep 17 00:00:00 2001 From: schneesu Date: Tue, 28 Sep 2021 10:05:17 +0800 Subject: [PATCH 126/141] fix: ignore ResourceNotFoundError in the first call of LazyDiscoverer.__search --- dynamic/discovery.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dynamic/discovery.py b/dynamic/discovery.py index 5c2f4ac4..dbf94101 100644 --- a/dynamic/discovery.py +++ b/dynamic/discovery.py @@ -237,7 +237,11 @@ def api_groups(self): return self.parse_api_groups(request_resources=False, update=True)['apis'].keys() def search(self, **kwargs): - results = self.__search(self.__build_search(**kwargs), self.__resources, []) + # In first call, ignore ResourceNotFoundError and set default value for results + try: + results = self.__search(self.__build_search(**kwargs), self.__resources, []) + except ResourceNotFoundError: + results = [] if not results: self.invalidate_cache() results = self.__search(self.__build_search(**kwargs), self.__resources, []) From 281f17ab237384bc1f5b022555635710b6e6aff3 Mon Sep 17 00:00:00 2001 From: abikouo Date: Thu, 16 Sep 2021 10:52:44 +0200 Subject: [PATCH 127/141] add support for server side apply --- dynamic/client.py | 18 ++++++++++++++++++ dynamic/test_client.py | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/dynamic/client.py b/dynamic/client.py index 353a481b..a81039b8 100644 --- a/dynamic/client.py +++ b/dynamic/client.py @@ -149,6 +149,20 @@ def patch(self, resource, body=None, name=None, namespace=None, **kwargs): return self.request('patch', path, body=body, content_type=content_type, **kwargs) + def server_side_apply(self, resource, body=None, name=None, namespace=None, force_conflicts=None, **kwargs): + body = self.serialize_body(body) + name = name or body.get('metadata', {}).get('name') + if not name: + raise ValueError("name is required to patch {}.{}".format(resource.group_version, resource.kind)) + if resource.namespaced: + namespace = self.ensure_namespace(resource, namespace, body) + + # force content type to 'application/apply-patch+yaml' + kwargs.update({'content_type': 'application/apply-patch+yaml'}) + path = resource.path(name=name, namespace=namespace) + + return self.request('patch', path, body=body, force_conflicts=force_conflicts, **kwargs) + def watch(self, resource, namespace=None, name=None, label_selector=None, field_selector=None, resource_version=None, timeout=None, watcher=None): """ Stream events for a resource from the Kubernetes API @@ -227,6 +241,10 @@ def request(self, method, path, body=None, **params): query_params.append(('orphanDependents', params['orphan_dependents'])) if params.get('dry_run') is not None: query_params.append(('dryRun', params['dry_run'])) + if params.get('field_manager') is not None: + query_params.append(('fieldManager', params['field_manager'])) + if params.get('force_conflicts') is not None: + query_params.append(('force', params['force_conflicts'])) header_params = params.get('header_params', {}) form_params = [] diff --git a/dynamic/test_client.py b/dynamic/test_client.py index ab1df93f..c31270bc 100644 --- a/dynamic/test_client.py +++ b/dynamic/test_client.py @@ -15,6 +15,7 @@ import time import unittest import uuid +import json from kubernetes.e2e_test import base from kubernetes.client import api_client @@ -394,6 +395,29 @@ def test_node_apis_partial_object_metadata(self): self.assertEqual('PartialObjectMetadataList', resp.kind) self.assertEqual('meta.k8s.io/v1', resp.apiVersion) + def test_server_side_apply_api(self): + client = DynamicClient(api_client.ApiClient(configuration=self.config)) + api = client.resources.get( + api_version='v1', kind='Pod') + + name = 'pod-' + short_uuid() + pod_manifest = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': {'labels': {'name': name}, + 'name': name}, + 'spec': {'containers': [{ + 'image': 'nginx', + 'name': 'nginx', + 'ports': [{'containerPort': 80, + 'protocol': 'TCP'}]}]}} + + body = json.dumps(pod_manifest).encode() + resp = api.server_side_apply( + name=name, namespace='default', body=body, + field_manager='kubernetes-unittests', dry_run="All") + self.assertEqual('kubernetes-unittests', resp.metadata.managedFields[0].manager) + class TestDynamicClientSerialization(unittest.TestCase): From 769bc57ec7b0271a7cb018becee8ad156cf82704 Mon Sep 17 00:00:00 2001 From: itaru2622 Date: Wed, 29 Sep 2021 09:18:55 +0900 Subject: [PATCH 128/141] add proxy authentication supporting for websocket (stream/ws_client.py) --- stream/ws_client.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 356440c8..2a60a8be 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -29,6 +29,7 @@ from six import StringIO from websocket import WebSocket, ABNF, enableTrace +from base64 import b64decode STDIN_CHANNEL = 0 STDOUT_CHANNEL = 1 @@ -445,11 +446,20 @@ def create_websocket(configuration, url, headers=None): ssl_opts['keyfile'] = configuration.key_file websocket = WebSocket(sslopt=ssl_opts, skip_utf8_validation=False) + connect_opt = { + 'header': header + } if configuration.proxy: proxy_url = urlparse(configuration.proxy) - websocket.connect(url, header=header, http_proxy_host=proxy_url.hostname, http_proxy_port=proxy_url.port) - else: - websocket.connect(url, header=header) + connect_opt.update({'http_proxy_host': proxy_url.hostname, 'http_proxy_port': proxy_url.port}) + if configuration.proxy_headers: + for key,value in configuration.proxy_headers.items(): + if key == 'proxy-authorization' and value.startswith('Basic'): + b64value = value.split()[1] + auth = b64decode(b64value).decode().split(':') + connect_opt.update({'http_proxy_auth': (auth[0], auth[1]) }) + + websocket.connect(url, **connect_opt) return websocket From 877727110956253be05e45dfb0e18bd094c54e90 Mon Sep 17 00:00:00 2001 From: itaru2622 Date: Tue, 5 Oct 2021 20:50:01 +0900 Subject: [PATCH 129/141] proxy authentication supporting for websocket (stream/ws_client.py), with unittest --- stream/ws_client.py | 22 ++++++++++++++-------- stream/ws_client_test.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 2a60a8be..419d28b2 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -449,18 +449,24 @@ def create_websocket(configuration, url, headers=None): connect_opt = { 'header': header } + + if configuration.proxy or coniguration.proxy_headers: + connect_opt = websocket_proxycare(connect_opt, configuration, url, headers) + + websocket.connect(url, **connect_opt) + return websocket + +def websocket_proxycare(connect_opt, configuration, url, headers): if configuration.proxy: proxy_url = urlparse(configuration.proxy) connect_opt.update({'http_proxy_host': proxy_url.hostname, 'http_proxy_port': proxy_url.port}) if configuration.proxy_headers: - for key,value in configuration.proxy_headers.items(): - if key == 'proxy-authorization' and value.startswith('Basic'): - b64value = value.split()[1] - auth = b64decode(b64value).decode().split(':') - connect_opt.update({'http_proxy_auth': (auth[0], auth[1]) }) - - websocket.connect(url, **connect_opt) - return websocket + for key,value in configuration.proxy_headers.items(): + if key == 'proxy-authorization' and value.startswith('Basic'): + b64value = value.split()[1] + auth = b64decode(b64value).decode().split(':') + connect_opt.update({'http_proxy_auth': (auth[0], auth[1]) }) + return(connect_opt) def websocket_call(configuration, _method, url, **kwargs): diff --git a/stream/ws_client_test.py b/stream/ws_client_test.py index a8f4049d..bfcd64d5 100644 --- a/stream/ws_client_test.py +++ b/stream/ws_client_test.py @@ -15,7 +15,21 @@ import unittest from .ws_client import get_websocket_url +from .ws_client import websocket_proxycare +from kubernetes.client.configuration import Configuration +try: + import urllib3 + urllib3.disable_warnings() +except ImportError: + pass + +def dictval(dict, key, default=None): + try: + val = dict[key] + except KeyError: + val = default + return val class WSClientTest(unittest.TestCase): @@ -32,6 +46,21 @@ def test_websocket_client(self): ]: self.assertEqual(get_websocket_http://github.com/url(url), ws_url) + def test_websocket_proxycare(self): + for proxy, idpass, expect_host, expect_port, expect_auth in [ + ( None, None, None, None, None ), + ( 'http://proxy.example.com:8080/', None, 'proxy.example.com', 8080, None ), + ( 'http://proxy.example.com:8080/', 'user:pass', 'proxy.example.com', 8080, ('user','pass')) + ]: + config = Configuration() + if proxy is not None: + setattr(config, 'proxy', proxy) + if idpass is not None: + setattr(config, 'proxy_headers', urllib3.util.make_headers(proxy_basic_auth=idpass)) + connect_opt = websocket_proxycare( {}, config, None, None) + self.assertEqual( dictval(connect_opt,'http_proxy_host'), expect_host) + self.assertEqual( dictval(connect_opt,'http_proxy_port'), expect_port) + self.assertEqual( dictval(connect_opt,'http_proxy_auth'), expect_auth) if __name__ == '__main__': unittest.main() From 59e7d115b22bcc2f640949ab880da39da5a0c046 Mon Sep 17 00:00:00 2001 From: itaru2622 Date: Sat, 9 Oct 2021 08:48:00 +0900 Subject: [PATCH 130/141] change base64decode to urlsafe_b64decode --- stream/ws_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 419d28b2..4b26ddd5 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -29,7 +29,7 @@ from six import StringIO from websocket import WebSocket, ABNF, enableTrace -from base64 import b64decode +from base64 import urlsafe_b64decode STDIN_CHANNEL = 0 STDOUT_CHANNEL = 1 @@ -464,7 +464,7 @@ def websocket_proxycare(connect_opt, configuration, url, headers): for key,value in configuration.proxy_headers.items(): if key == 'proxy-authorization' and value.startswith('Basic'): b64value = value.split()[1] - auth = b64decode(b64value).decode().split(':') + auth = urlsafe_b64decode(b64value).decode().split(':') connect_opt.update({'http_proxy_auth': (auth[0], auth[1]) }) return(connect_opt) From f23b2840f88ee51d96089555fae6596d77242112 Mon Sep 17 00:00:00 2001 From: itaru2622 Date: Sun, 10 Oct 2021 11:46:38 +0900 Subject: [PATCH 131/141] fix typo in proxy auth (stream/ws_client.py) --- stream/ws_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 4b26ddd5..732ac470 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -450,7 +450,7 @@ def create_websocket(configuration, url, headers=None): 'header': header } - if configuration.proxy or coniguration.proxy_headers: + if configuration.proxy or configuration.proxy_headers: connect_opt = websocket_proxycare(connect_opt, configuration, url, headers) websocket.connect(url, **connect_opt) From 95e2e85af5928546b92b9fe06554b48db7f3baaf Mon Sep 17 00:00:00 2001 From: DiptoChakrabarty Date: Fri, 15 Oct 2021 19:14:37 +0530 Subject: [PATCH 132/141] closes open file descriptors to prevent leaks --- config/kube_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/kube_config.py b/config/kube_config.py index 584b8a41..e5368f47 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -70,7 +70,8 @@ def _create_temp_file_with_content(content, temp_file_path=None): return _temp_files[content_key] if temp_file_path and not os.path.isdir(temp_file_path): os.makedirs(name=temp_file_path) - _, name = tempfile.mkstemp(dir=temp_file_path) + fd, name = tempfile.mkstemp(dir=temp_file_path) + os.close(fd) _temp_files[content_key] = name with open(name, 'wb') as fd: fd.write(content.encode() if isinstance(content, str) else content) From 4ef4139e77eb435faf74944be90ce7f8bbe2e58f Mon Sep 17 00:00:00 2001 From: itaru2622 Date: Mon, 18 Oct 2021 09:30:09 +0900 Subject: [PATCH 133/141] add no_proxy support to stream/ws_client.py --- stream/ws_client.py | 7 +++++++ stream/ws_client_test.py | 18 ++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 732ac470..68840593 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -30,6 +30,7 @@ from websocket import WebSocket, ABNF, enableTrace from base64 import urlsafe_b64decode +from requests.utils import should_bypass_proxies STDIN_CHANNEL = 0 STDOUT_CHANNEL = 1 @@ -457,6 +458,12 @@ def create_websocket(configuration, url, headers=None): return websocket def websocket_proxycare(connect_opt, configuration, url, headers): + """ An internal function to be called in api-client when a websocket + create is requested. + """ + if configuration.no_proxy: + connect_opt.update({ 'http_no_proxy': configuration.no_proxy.split(',') }) + if configuration.proxy: proxy_url = urlparse(configuration.proxy) connect_opt.update({'http_proxy_host': proxy_url.hostname, 'http_proxy_port': proxy_url.port}) diff --git a/stream/ws_client_test.py b/stream/ws_client_test.py index bfcd64d5..a7a11f5c 100644 --- a/stream/ws_client_test.py +++ b/stream/ws_client_test.py @@ -47,20 +47,30 @@ def test_websocket_client(self): self.assertEqual(get_websocket_http://github.com/url(url), ws_url) def test_websocket_proxycare(self): - for proxy, idpass, expect_host, expect_port, expect_auth in [ - ( None, None, None, None, None ), - ( 'http://proxy.example.com:8080/', None, 'proxy.example.com', 8080, None ), - ( 'http://proxy.example.com:8080/', 'user:pass', 'proxy.example.com', 8080, ('user','pass')) + for proxy, idpass, no_proxy, expect_host, expect_port, expect_auth, expect_noproxy in [ + ( None, None, None, None, None, None, None ), + ( 'http://proxy.example.com:8080/', None, None, 'proxy.example.com', 8080, None, None ), + ( 'http://proxy.example.com:8080/', 'user:pass', None, 'proxy.example.com', 8080, ('user','pass'), None), + ( 'http://proxy.example.com:8080/', 'user:pass', '', 'proxy.example.com', 8080, ('user','pass'), None), + ( 'http://proxy.example.com:8080/', 'user:pass', '*', 'proxy.example.com', 8080, ('user','pass'), ['*']), + ( 'http://proxy.example.com:8080/', 'user:pass', '.example.com', 'proxy.example.com', 8080, ('user','pass'), ['.example.com']), + ( 'http://proxy.example.com:8080/', 'user:pass', 'localhost,.local,.example.com', 'proxy.example.com', 8080, ('user','pass'), ['localhost','.local','.example.com']), ]: + # setup input config = Configuration() if proxy is not None: setattr(config, 'proxy', proxy) if idpass is not None: setattr(config, 'proxy_headers', urllib3.util.make_headers(proxy_basic_auth=idpass)) + if no_proxy is not None: + setattr(config, 'no_proxy', no_proxy) + # setup done + # test starts connect_opt = websocket_proxycare( {}, config, None, None) self.assertEqual( dictval(connect_opt,'http_proxy_host'), expect_host) self.assertEqual( dictval(connect_opt,'http_proxy_port'), expect_port) self.assertEqual( dictval(connect_opt,'http_proxy_auth'), expect_auth) + self.assertEqual( dictval(connect_opt,'http_no_proxy'), expect_noproxy) if __name__ == '__main__': unittest.main() From d47030ac835e00b1bc315349dffa66c252967b1e Mon Sep 17 00:00:00 2001 From: aagten Date: Tue, 9 Nov 2021 21:41:53 +0100 Subject: [PATCH 134/141] Make socket Windows-proof --- stream/ws_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 68840593..4e164e86 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -280,7 +280,7 @@ def __init__(self, ix, port_number): # between the python application and the kubernetes websocket. The self.python # half of the socket pair is used by the _proxy method to receive and send data # to the running python application. - s, self.python = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + s, self.python = socket.socketpair() # The self.socket half of the pair is used by the python application to send # and receive data to the eventual pod port. It is wrapped in the _Socket class # because a socket pair is an AF_UNIX socket, not a AF_INET socket. This allows From 8b306c0f570152d8bbf65736a74b7895d20cf246 Mon Sep 17 00:00:00 2001 From: WalkerWang731 Date: Wed, 17 Nov 2021 16:53:22 +0800 Subject: [PATCH 135/141] add a new method of config.kube_config.new_client_from_config_dict Signed-off-by: WalkerWang731 --- config/__init__.py | 2 +- config/kube_config.py | 18 ++++++++++++++++++ config/kube_config_test.py | 9 ++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/config/__init__.py b/config/__init__.py index e1bf7f57..69ed7f1f 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -18,7 +18,7 @@ from .incluster_config import load_incluster_config from .kube_config import (KUBE_CONFIG_DEFAULT_LOCATION, list_kube_config_contexts, load_kube_config, - load_kube_config_from_dict, new_client_from_config) + load_kube_config_from_dict, new_client_from_config, new_client_from_config_dict) def load_config(**kwargs): diff --git a/config/kube_config.py b/config/kube_config.py index e5368f47..0b6fe56e 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -871,3 +871,21 @@ def new_client_from_config( client_configuration=client_config, persist_config=persist_config) return ApiClient(configuration=client_config) + + +def new_client_from_config_dict( + config_dict=None, + context=None, + persist_config=True, + temp_file_path=None): + """ + Loads configuration the same as load_kube_config_from_dict but returns an ApiClient + to be used with any API object. This will allow the caller to concurrently + talk with multiple clusters. + """ + client_config = type.__call__(Configuration) + load_kube_config_from_dict(config_dict=config_dict, context=context, + client_configuration=client_config, + persist_config=persist_config, + temp_file_path=temp_file_path) + return ApiClient(configuration=client_config) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index c33ffed7..b9030759 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -37,7 +37,7 @@ _get_kube_config_loader, _get_kube_config_loader_for_yaml_file, list_kube_config_contexts, load_kube_config, - load_kube_config_from_dict, new_client_from_config) + load_kube_config_from_dict, new_client_from_config, new_client_from_config_dict) BEARER_TOKEN_FORMAT = "Bearer %s" @@ -1351,6 +1351,13 @@ def test_new_client_from_config(self): self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, client.configuration.api_key['authorization']) + def test_new_client_from_config_dict(self): + client = new_client_from_config_dict( + config_dict=self.TEST_KUBE_CONFIG, context="simple_token") + self.assertEqual(TEST_HOST, client.configuration.host) + self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, + client.configuration.api_key['authorization']) + def test_no_users_section(self): expected = FakeConfig(host=TEST_HOST) actual = FakeConfig() From bc697ae8f089b048a8feed0b73b0afc0be3435cf Mon Sep 17 00:00:00 2001 From: Ping He Date: Wed, 24 Nov 2021 15:14:10 +0800 Subject: [PATCH 136/141] Fix leaderelection/example.py, now works in package. Signed-off-by: Ping He --- leaderelection/example.py | 6 +++--- leaderelection/resourcelock/configmaplock.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/leaderelection/example.py b/leaderelection/example.py index b8d8e616..3b3336c8 100644 --- a/leaderelection/example.py +++ b/leaderelection/example.py @@ -14,9 +14,9 @@ import uuid from kubernetes import client, config -from leaderelection import leaderelection -from leaderelection.resourcelock.configmaplock import ConfigMapLock -from leaderelection import electionconfig +from kubernetes.leaderelection import leaderelection +from kubernetes.leaderelection.resourcelock.configmaplock import ConfigMapLock +from kubernetes.leaderelection import electionconfig # Authenticate using config file diff --git a/leaderelection/resourcelock/configmaplock.py b/leaderelection/resourcelock/configmaplock.py index 8d155e29..54a7bb43 100644 --- a/leaderelection/resourcelock/configmaplock.py +++ b/leaderelection/resourcelock/configmaplock.py @@ -15,7 +15,7 @@ from kubernetes.client.rest import ApiException from kubernetes import client, config from kubernetes.client.api_client import ApiClient -from leaderelection.leaderelectionrecord import LeaderElectionRecord +from ..leaderelectionrecord import LeaderElectionRecord import json import logging logging.basicConfig(level=logging.INFO) From 18828d92cca7e9736d310aab5b2c1f22f0d7f9e7 Mon Sep 17 00:00:00 2001 From: John Sun Date: Mon, 29 Nov 2021 17:33:52 +1100 Subject: [PATCH 137/141] Use select.poll() for exec on linux/darwin --- stream/ws_client.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 356440c8..9a9442e5 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -11,6 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import sys from kubernetes.client.rest import ApiException, ApiValueError @@ -165,8 +166,25 @@ def update(self, timeout=0): if not self.sock.connected: self._connected = False return - r, _, _ = select.select( - (self.sock.sock, ), (), (), timeout) + + # The options here are: + # select.select() - this will work on most OS, however, it has a + # limitation of only able to read fd numbers up to 1024. + # i.e. does not scale well. This was the original + # implementation. + # select.poll() - this will work on most unix based OS, but not as + # efficient as epoll. Will work for fd numbers above 1024. + # select.epoll() - newest and most efficient way of polling. + # However, only works on linux. + if sys.platform.startswith('linux') or sys.platform in ['darwin']: + poll = select.poll() + poll.register(self.sock.sock, select.POLLIN) + r = poll.poll(timeout) + poll.unregister(self.sock.sock) + else: + r, _, _ = select.select( + (self.sock.sock, ), (), (), timeout) + if r: op_code, frame = self.sock.recv_data_frame(True) if op_code == ABNF.OPCODE_CLOSE: From 79e066a0d46a8e7b84366fdd1903965d60ca92a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Gasser?= Date: Mon, 13 Dec 2021 19:32:32 -0500 Subject: [PATCH 138/141] fix: WSClient.returncode not idempotent --- stream/ws_client.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/stream/ws_client.py b/stream/ws_client.py index 4e164e86..89ad5c2d 100644 --- a/stream/ws_client.py +++ b/stream/ws_client.py @@ -63,6 +63,7 @@ def __init__(self, configuration, url, headers, capture_all): self._all = _IgnoredIO() self.sock = create_websocket(configuration, url, headers) self._connected = True + self._returncode = None def peek_channel(self, channel, timeout=0): """Peek a channel and return part of the input, @@ -210,12 +211,14 @@ def returncode(self): if self.is_open(): return None else: - err = self.read_channel(ERROR_CHANNEL) - err = yaml.safe_load(err) - if err['status'] == "Success": - return 0 - return int(err['details']['causes'][0]['message']) - + if self._returncode is None: + err = self.read_channel(ERROR_CHANNEL) + err = yaml.safe_load(err) + if err['status'] == "Success": + self._returncode = 0 + else: + self._returncode = int(err['details']['causes'][0]['message']) + return self._returncode def close(self, **kwargs): """ From 1c5bf586f0882c81c03181588830887345703ca5 Mon Sep 17 00:00:00 2001 From: April Schleck Date: Thu, 23 Dec 2021 14:46:23 -0800 Subject: [PATCH 139/141] Run kubeconfig exec commands in the correct directory. This fixes configs that rely on relative paths. --- config/exec_provider.py | 4 +++- config/exec_provider_test.py | 21 +++++++++++++++------ config/kube_config.py | 4 ++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/config/exec_provider.py b/config/exec_provider.py index 4008f2e8..ef3fac66 100644 --- a/config/exec_provider.py +++ b/config/exec_provider.py @@ -31,7 +31,7 @@ class ExecProvider(object): * caching """ - def __init__(self, exec_config): + def __init__(self, exec_config, cwd): """ exec_config must be of type ConfigNode because we depend on safe_get(self, key) to correctly handle optional exec provider @@ -53,6 +53,7 @@ def __init__(self, exec_config): value = item['value'] additional_vars[name] = value self.env.update(additional_vars) + self.cwd = cwd def run(self, previous_response=None): kubernetes_exec_info = { @@ -69,6 +70,7 @@ def run(self, previous_response=None): self.args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=self.cwd, env=self.env, universal_newlines=True) (stdout, stderr) = process.communicate() diff --git a/config/exec_provider_test.py b/config/exec_provider_test.py index 44579beb..a545b556 100644 --- a/config/exec_provider_test.py +++ b/config/exec_provider_test.py @@ -47,7 +47,7 @@ def test_missing_input_keys(self): ConfigNode('test3', {'apiVersion': ''})] for exec_config in exec_configs: with self.assertRaises(ConfigException) as context: - ExecProvider(exec_config) + ExecProvider(exec_config, None) self.assertIn('exec: malformed request. missing key', context.exception.args[0]) @@ -57,7 +57,7 @@ def test_error_code_returned(self, mock): instance.wait.return_value = 1 instance.communicate.return_value = ('', '') with self.assertRaises(ConfigException) as context: - ep = ExecProvider(self.input_ok) + ep = ExecProvider(self.input_ok, None) ep.run() self.assertIn('exec: process returned %d' % instance.wait.return_value, context.exception.args[0]) @@ -68,7 +68,7 @@ def test_nonjson_output_returned(self, mock): instance.wait.return_value = 0 instance.communicate.return_value = ('', '') with self.assertRaises(ConfigException) as context: - ep = ExecProvider(self.input_ok) + ep = ExecProvider(self.input_ok, None) ep.run() self.assertIn('exec: failed to decode process output', context.exception.args[0]) @@ -102,7 +102,7 @@ def test_missing_output_keys(self, mock): for output in outputs: instance.communicate.return_value = (output, '') with self.assertRaises(ConfigException) as context: - ep = ExecProvider(self.input_ok) + ep = ExecProvider(self.input_ok, None) ep.run() self.assertIn('exec: malformed response. missing key', context.exception.args[0]) @@ -123,7 +123,7 @@ def test_mismatched_api_version(self, mock): """ % wrong_api_version instance.communicate.return_value = (output, '') with self.assertRaises(ConfigException) as context: - ep = ExecProvider(self.input_ok) + ep = ExecProvider(self.input_ok, None) ep.run() self.assertIn( 'exec: plugin api version %s does not match' % @@ -135,11 +135,20 @@ def test_ok_01(self, mock): instance = mock.return_value instance.wait.return_value = 0 instance.communicate.return_value = (self.output_ok, '') - ep = ExecProvider(self.input_ok) + ep = ExecProvider(self.input_ok, None) result = ep.run() self.assertTrue(isinstance(result, dict)) self.assertTrue('token' in result) + @mock.patch('subprocess.Popen') + def test_run_in_dir(self, mock): + instance = mock.return_value + instance.wait.return_value = 0 + instance.communicate.return_value = (self.output_ok, '') + ep = ExecProvider(self.input_ok, '/some/directory') + ep.run() + self.assertEqual(mock.call_args.kwargs['cwd'], '/some/directory') + if __name__ == '__main__': unittest.main() diff --git a/config/kube_config.py b/config/kube_config.py index a04a6e3e..f37ed43e 100644 --- a/config/kube_config.py +++ b/config/kube_config.py @@ -483,7 +483,8 @@ def _load_from_exec_plugin(self): if 'exec' not in self._user: return try: - status = ExecProvider(self._user['exec']).run() + base_path = self._get_base_path(self._cluster.path) + status = ExecProvider(self._user['exec'], base_path).run() if 'token' in status: self.token = "Bearer %s" % status['token'] elif 'clientCertificateData' in status: @@ -493,7 +494,6 @@ def _load_from_exec_plugin(self): logging.error('exec: missing clientKeyData field in ' 'plugin output') return None - base_path = self._get_base_path(self._cluster.path) self.cert_file = FileOrData( status, None, data_key_name='clientCertificateData', From 6efd33d5c16243929d32139d3b0d0bc34820ea7b Mon Sep 17 00:00:00 2001 From: April Schleck Date: Wed, 5 Jan 2022 17:56:07 -0800 Subject: [PATCH 140/141] Add a test to kube_config_test to check the cwd of the ExecProvider --- config/kube_config_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/config/kube_config_test.py b/config/kube_config_test.py index 6ac3db2d..02127d15 100644 --- a/config/kube_config_test.py +++ b/config/kube_config_test.py @@ -1441,6 +1441,20 @@ def test_user_exec_auth_certificates(self, mock): active_context="exec_cred_user_certificate").load_and_set(actual) self.assertEqual(expected, actual) + @mock.patch('kubernetes.config.kube_config.ExecProvider.run', autospec=True) + def test_user_exec_cwd(self, mock): + capture = {} + def capture_cwd(exec_provider): + capture['cwd'] = exec_provider.cwd + mock.side_effect = capture_cwd + + expected = "/some/random/path" + KubeConfigLoader( + config_dict=self.TEST_KUBE_CONFIG, + active_context="exec_cred_user", + config_base_path=expected).load_and_set(FakeConfig()) + self.assertEqual(expected, capture['cwd']) + def test_user_cmd_path(self): A = namedtuple('A', ['token', 'expiry']) token = "dummy" From 4539902540c19bd824944e6aebad7c0998b648b2 Mon Sep 17 00:00:00 2001 From: Yu Liao Date: Tue, 1 Feb 2022 09:44:14 -0800 Subject: [PATCH 141/141] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f916e343..9804e0d5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![Build Status](https://travis-ci.org/kubernetes-client/python-base.svg?branch=master)](https://travis-ci.org/kubernetes-client/python-base) +**This repo has been merged into the [python client](https://github.com/kubernetes-client/python/tree/master/kubernetes/base). Please file issues, contribute PRs there. This repo is kept open to provide the history of issues and PRs.** + This is the utility part of the [python client](https://github.com/kubernetes-client/python). It has been added to the main repo using git submodules. This structure allow other developers to create their own kubernetes client and still use standard kubernetes python utilities.