From cf5d796cf43991149165b8245972989ce8d0a22f Mon Sep 17 00:00:00 2001 From: Davanum Srinivas Date: Wed, 25 Jan 2017 16:02:06 -0500 Subject: [PATCH 01/12] Ability to run e2e tests multiple times --- kubernetes/e2e_test/test_client.py | 48 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/kubernetes/e2e_test/test_client.py b/kubernetes/e2e_test/test_client.py index 5ccdf3c8f..2d844e622 100644 --- a/kubernetes/e2e_test/test_client.py +++ b/kubernetes/e2e_test/test_client.py @@ -55,7 +55,6 @@ class TestClient(unittest.TestCase): api = core_v1_api.CoreV1Api(client) name = 'test-' + str(uuid.uuid4()) - pod_manifest = {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'color': 'blue', 'name': name}, @@ -84,25 +83,26 @@ class TestClient(unittest.TestCase): client = api_client.ApiClient('http://127.0.0.1:8080/') api = core_v1_api.CoreV1Api(client) + name = 'frontend-' + str(uuid.uuid4()) service_manifest = {'apiVersion': 'v1', 'kind': 'Service', - 'metadata': {'labels': {'name': 'frontend'}, - 'name': 'frontend', + 'metadata': {'labels': {'name': name}, + 'name': name, 'resourceversion': 'v1'}, 'spec': {'ports': [{'name': 'port', 'port': 80, 'protocol': 'TCP', 'targetPort': 80}], - 'selector': {'name': 'frontend'}}} + 'selector': {'name': name}}} resp = api.create_namespaced_service(body=service_manifest, namespace='default') - self.assertEqual('frontend', resp.metadata.name) + self.assertEqual(name, resp.metadata.name) self.assertTrue(resp.status) - resp = api.read_namespaced_service(name='frontend', + resp = api.read_namespaced_service(name=name, namespace='default') - self.assertEqual('frontend', resp.metadata.name) + self.assertEqual(name, resp.metadata.name) self.assertTrue(resp.status) service_manifest['spec']['ports'] = [{'name': 'new', @@ -110,12 +110,12 @@ class TestClient(unittest.TestCase): 'protocol': 'TCP', 'targetPort': 8080}] resp = api.patch_namespaced_service(body=service_manifest, - name='frontend', + name=name, namespace='default') self.assertEqual(2, len(resp.spec.ports)) self.assertTrue(resp.status) - resp = api.delete_namespaced_service(name='frontend', + resp = api.delete_namespaced_service(name=name, namespace='default') @unittest.skipUnless( @@ -124,15 +124,16 @@ class TestClient(unittest.TestCase): client = api_client.ApiClient('http://127.0.0.1:8080/') api = core_v1_api.CoreV1Api(client) + name = 'frontend-' + str(uuid.uuid4()) rc_manifest = { 'apiVersion': 'v1', 'kind': 'ReplicationController', - 'metadata': {'labels': {'name': 'frontend'}, - 'name': 'frontend'}, + 'metadata': {'labels': {'name': name}, + 'name': name}, 'spec': {'replicas': 2, - 'selector': {'name': 'frontend'}, + 'selector': {'name': name}, 'template': {'metadata': { - 'labels': {'name': 'frontend'}}, + 'labels': {'name': name}}, 'spec': {'containers': [{ 'image': 'nginx', 'name': 'nginx', @@ -141,16 +142,16 @@ class TestClient(unittest.TestCase): resp = api.create_namespaced_replication_controller( body=rc_manifest, namespace='default') - self.assertEqual('frontend', resp.metadata.name) + self.assertEqual(name, resp.metadata.name) self.assertEqual(2, resp.spec.replicas) resp = api.read_namespaced_replication_controller( - name='frontend', namespace='default') - self.assertEqual('frontend', resp.metadata.name) + name=name, namespace='default') + self.assertEqual(name, resp.metadata.name) self.assertEqual(2, resp.spec.replicas) resp = api.delete_namespaced_replication_controller( - name='frontend', body={}, namespace='default') + name=name, body={}, namespace='default') @unittest.skipUnless( @@ -159,11 +160,12 @@ class TestClient(unittest.TestCase): client = api_client.ApiClient('http://127.0.0.1:8080/') api = core_v1_api.CoreV1Api(client) + name = 'test-configmap-' + str(uuid.uuid4()) test_configmap = { "kind": "ConfigMap", "apiVersion": "v1", "metadata": { - "name": "test-configmap", + "name": name, }, "data": { "config.json": "{\"command\":\"/usr/bin/mysqld_safe\"}", @@ -174,18 +176,18 @@ class TestClient(unittest.TestCase): resp = api.create_namespaced_config_map( body=test_configmap, namespace='default' ) - self.assertEqual('test-configmap', resp.metadata.name) + self.assertEqual(name, resp.metadata.name) resp = api.read_namespaced_config_map( - name='test-configmap', namespace='default') - self.assertEqual('test-configmap', resp.metadata.name) + name=name, namespace='default') + self.assertEqual(name, resp.metadata.name) test_configmap['data']['config.json'] = "{}" resp = api.patch_namespaced_config_map( - name='test-configmap', namespace='default', body=test_configmap) + name=name, namespace='default', body=test_configmap) resp = api.delete_namespaced_config_map( - name='test-configmap', body={}, namespace='default') + name=name, body={}, namespace='default') resp = api.list_namespaced_config_map('kube-system', pretty=True) self.assertEqual([], resp.items) From 03731f1003ae62d360d596423843a5bff2f79d4a Mon Sep 17 00:00:00 2001 From: Davanum Srinivas Date: Wed, 25 Jan 2017 16:44:55 -0500 Subject: [PATCH 02/12] Add e2e for deployment and daemonsets --- kubernetes/e2e_test/test_client.py | 88 +++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/kubernetes/e2e_test/test_client.py b/kubernetes/e2e_test/test_client.py index 2d844e622..1569fb787 100644 --- a/kubernetes/e2e_test/test_client.py +++ b/kubernetes/e2e_test/test_client.py @@ -25,9 +25,11 @@ and then run this test import unittest import urllib3 import uuid +import yaml from kubernetes.client import api_client from kubernetes.client.apis import core_v1_api +from kubernetes.client.apis import extensions_v1beta1_api def _is_k8s_running(): @@ -39,6 +41,26 @@ def _is_k8s_running(): class TestClient(unittest.TestCase): + @unittest.skipUnless( + _is_k8s_running(), "Kubernetes is not available") + def test_read_namespaces(self): + client = api_client.ApiClient('http://127.0.0.1:8080/') + api = core_v1_api.CoreV1Api(client) + + expected_namespaces = ('default', 'kube-system') + for ns in expected_namespaces: + api.read_namespace(name=ns) + + @unittest.skipUnless( + _is_k8s_running(), "Kubernetes is not available") + def test_read_services(self): + client = api_client.ApiClient('http://127.0.0.1:8080/') + api = core_v1_api.CoreV1Api(client) + + expected_services = ('kubernetes',) + for service in expected_services: + api.read_namespaced_service(service, 'default') + @unittest.skipUnless( _is_k8s_running(), "Kubernetes is not available") def test_list_endpoints(self): @@ -48,6 +70,35 @@ class TestClient(unittest.TestCase): endpoints = api.list_endpoints_for_all_namespaces() self.assertTrue(len(endpoints.items) > 0) + @unittest.skipUnless( + _is_k8s_running(), "Kubernetes is not available") + def test_create_deployment(self): + client = api_client.ApiClient('http://127.0.0.1:8080/') + api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) + name = 'nginx-deployment-' + str(uuid.uuid4()) + deployment = '''apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: %s +spec: + replicas: 3 + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.7.9 + ports: + - containerPort: 80 +''' + resp = api.create_namespaced_deployment( + body=yaml.load(deployment % name), + namespace="default") + resp = api.read_namespaced_deployment(name, 'default') + self.assertIsNotNone(resp) + @unittest.skipUnless( _is_k8s_running(), "Kubernetes is not available") def test_pod_apis(self): @@ -153,7 +204,6 @@ class TestClient(unittest.TestCase): resp = api.delete_namespaced_replication_controller( name=name, body={}, namespace='default') - @unittest.skipUnless( _is_k8s_running(), "Kubernetes is not available") def test_configmap_apis(self): @@ -201,4 +251,38 @@ class TestClient(unittest.TestCase): for item in api.list_node().items: node = api.read_node(name=item.metadata.name) self.assertTrue(len(node.metadata.labels) > 0) - self.assertTrue(isinstance(node.metadata.labels, dict)) \ No newline at end of file + self.assertTrue(isinstance(node.metadata.labels, dict)) + + @unittest.skipUnless( + _is_k8s_running(), "Kubernetes is not available") + def test_create_daemonset(self): + client = api_client.ApiClient('http://127.0.0.1:8080/') + api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) + name = 'nginx-app-' + str(uuid.uuid4()) + daemonset = { + 'apiVersion': 'extensions/v1beta1', + 'kind': 'DaemonSet', + 'metadata': { + 'labels': {'app': 'nginx'}, + 'name': '%s' % name, + }, + 'spec': { + 'template': { + 'metadata': { + 'labels': {'app': 'nginx'}, + 'name': name}, + 'spec': { + 'containers': [ + {'name': 'nginx-app', + 'image': 'nginx:1.10'}, + ], + }, + }, + 'updateStrategy': { + 'type': 'RollingUpdate', + }, + } + } + resp = api.create_namespaced_daemon_set('default', body=daemonset) + resp = api.read_namespaced_daemon_set(name, 'default') + self.assertIsNotNone(resp) \ No newline at end of file From f2e6d83d07d91a6d5e4aa6fc21093866a14b512e Mon Sep 17 00:00:00 2001 From: Mehdy Bohlool Date: Wed, 25 Jan 2017 14:30:25 -0800 Subject: [PATCH 03/12] Update CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a4ad7f1..34cdfc1a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# v1.0.0b1 + +- Support insecure-skip-tls-verify config flag #99 +- Added example for using yaml files as models #63 +- Added end to end tests #41, #94 +- Bugfix: Fix ValueError in list_namespaced_config_map #104 +- Bugfix: Export missing models #101 +- Bugfix: Patch operations #93 + # v1.0.0a5 - Bugfix: Missing fields in some models #85, kubernetes/kubernetes#39465 From f3d4014a28e389a057fc57b1933cf436c62dda9e Mon Sep 17 00:00:00 2001 From: mbohlool Date: Wed, 25 Jan 2017 14:55:52 -0800 Subject: [PATCH 04/12] Add release process [skip ci] --- devel/release.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 devel/release.md diff --git a/devel/release.md b/devel/release.md new file mode 100644 index 000000000..091717d6d --- /dev/null +++ b/devel/release.md @@ -0,0 +1,136 @@ +# Release process + +Release process of python client involve creating (or updating) a release +branch, update release tags, create distribution packages and upload them to +pip. + +## Change logs +Make sure changes logs are up to date [here](https://github.com/kubernetes-incubator/client-python/blob/master/CHANGELOG.md). +If they are not, follow commits added after last release and update/commit +the change logs to master. + +## Release branch + +Release branch name should have release-x.x format. All minor and pre-releases +should be on the same branch. To update an existing branch: + +```bash +export RELEASE_BRANCH=release-x.x +git checkout RELEASE_BRANCH +git fetch upstream +git pull upstream/master +git push origin RELEASE_BRANCH +``` + +You may need to fix some conflicts. For auto-generated files, you can commit +either version. They will be updated to the current version in the next step. + +## Sanity check generated client +We need to make sure there is no API changes after running update client +scripts. Such changes should be committed to master branch first. Run this +command: + +```bash +scripts/update-client.sh +``` + +And make sure there is no API change (version number changes should be fine +as they will be updated in next step anyway). Do not commit any changes at +this step and go back to master branch if there is any API changes. + +## Update release tags + +Release tags are in scripts/constants.py file. These are the constants you may +need to update: + +CLIENT_VERSION: Client version should follow x.y.zDn where x,y,z are version +numbers (integers) and D is one of "a" for alpha or "b" for beta and n is the +pre-release number. For a final release, "Dn" part should be omitted. Examples: +1.0.0a1, 2.0.1b2, 1.5.1 +SPEC_VERSION: This would be the kubernetes OpenAPI spec version. It should be +deprecated after kubernetes/kubernetes#37055 takes effect. +DEVELOPMENT_STATUS: Update it to one of the values of "Development Status" +in [this list](https://pypi.python.org/pypi?%3Aaction=list_classifiers). + +after changing constants to proper versions, update the client using this +command: + +```bash +scripts/update-client.sh +``` + +and commit changes (should be only version number changes) to the release branch. +Name the commit something like "Update version constants for XXX release". + +```bash +git push upstream RELEASE_BRANCH +``` + +## Make distribution packages +First make sure you are using a clean version of python. Use virtualenv and +pyenv packages, make sure you are using python 2.7.12. I would normally do this +on a clean machine: + +(install [pyenv](https://github.com/yyuu/pyenv#installation)) +(install [pip](https://pip.pypa.io/en/stable/installing/)) +(install [virtualenv](https://virtualenv.pypa.io/en/stable/installation/)) + +```bash +git clean -xdf +pyenv install 2.7.12 +pyenv global 2.7.12 +virtualenv .release +source .release/bin/activate +python --version # Make sure you get Python 2.7.12 +pip install twine +``` + +Now we need to create a "~/.pypirc" with this content: + +``` +[distutils] +index-servers=pypi + +[pypi] +repository = https://upload.pypi.org/legacy/ +username = kubernetes +``` + +TODO: we should be able to pass these parameters to twine directly. My first attempt failed. + +Now that the environment is ready, lets create distribution packages: + +```bash +python setup.py sdist +python setup.py bdist_wheel --universal +ls dist/ +``` + +You should see two files in dist folder. kubernetes*.whl and kubernetes*.tar.gz. + +TODO: We need a dry-run option an some way to test package upload process to pypi. + +If everything looks good, run this command to upload packages to pypi: + +``` +twine upload dist/* +``` + +## Create github release + +Create a gihub release by starting from +[this page(https://github.com/kubernetes-incubator/client-python/releases). +Click Deaft new release button. Name the tag the same as CLIENT_VERSION. Change +the target branch to "release-x.y" + + +ref: https://packaging.python.org/distributing/ + +## Cleanup + +```bash +deactivate +rm -rf .release +``` + +TODO: Convert steps in this document to an (semi-) automated script. From 2a321d4df93c00c7130102227040622e056e11c8 Mon Sep 17 00:00:00 2001 From: mbohlool Date: Thu, 26 Jan 2017 05:36:10 -0800 Subject: [PATCH 05/12] Add download stat how-to page [skip ci] --- devel/stats.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 devel/stats.md diff --git a/devel/stats.md b/devel/stats.md new file mode 100644 index 000000000..8a960e9ce --- /dev/null +++ b/devel/stats.md @@ -0,0 +1,26 @@ +# Download Statistics + +Pypi stores download information in a [BigQuery public dataset](https://bigquery.cloud.google.com/dataset/the-psf:pypi). It can be queried to get detail infomration about downloads. For example, to get number of downloads per version, you can run this query: + +```sql +SELECT + file.version, + COUNT(*) as total_downloads, +FROM + TABLE_DATE_RANGE( + [the-psf:pypi.downloads], + TIMESTAMP("20161120"), + CURRENT_TIMESTAMP() + ) +where file.project == "kubernetes" +GROUP BY + file.version +ORDER BY + total_downloads DESC +LIMIT 20 +``` + +More example queries can be found [here](https://gist.github.com/alex/4f100a9592b05e9b4d63) + +Reference: https://mail.python.org/pipermail/distutils-sig/2016-May/028986.html + From 38aaec0f479523809f786bd97969f8ff15cf1079 Mon Sep 17 00:00:00 2001 From: Davanum Srinivas Date: Wed, 25 Jan 2017 20:46:36 -0500 Subject: [PATCH 06/12] Refactor tests * separate batch, extensions and the regular apis into separate * added delete for some tests * drop '_' for is_k8s_running * remove comments that do not add value --- kubernetes/e2e_test/base.py | 21 +++++ kubernetes/e2e_test/test_batch.py | 55 +++++++++++ kubernetes/e2e_test/test_client.py | 126 ++----------------------- kubernetes/e2e_test/test_extensions.py | 93 ++++++++++++++++++ 4 files changed, 176 insertions(+), 119 deletions(-) create mode 100644 kubernetes/e2e_test/base.py create mode 100644 kubernetes/e2e_test/test_batch.py create mode 100644 kubernetes/e2e_test/test_extensions.py diff --git a/kubernetes/e2e_test/base.py b/kubernetes/e2e_test/base.py new file mode 100644 index 000000000..84e5668e2 --- /dev/null +++ b/kubernetes/e2e_test/base.py @@ -0,0 +1,21 @@ +# 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 urllib3 + + +def is_k8s_running(): + try: + urllib3.PoolManager().request('GET', '127.0.0.1:8080') + return True + except urllib3.exceptions.HTTPError: + return False diff --git a/kubernetes/e2e_test/test_batch.py b/kubernetes/e2e_test/test_batch.py new file mode 100644 index 000000000..ac4b3fc6e --- /dev/null +++ b/kubernetes/e2e_test/test_batch.py @@ -0,0 +1,55 @@ +# -*- 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 unittest +import uuid + +from kubernetes.client import api_client +from kubernetes.client.apis import batch_v1_api +from kubernetes.e2e_test import base + + +class TestClientBatch(unittest.TestCase): + @unittest.skipUnless( + base.is_k8s_running(), "Kubernetes is not available") + def test_job_apis(self): + client = api_client.ApiClient('http://127.0.0.1:8080/') + api = batch_v1_api.BatchV1Api(client) + + name = 'test-job-' + str(uuid.uuid4()) + job_manifest = { + 'kind': 'Job', + 'spec': { + 'template': + {'spec': + {'containers': [ + {'image': 'busybox', + 'name': name, + 'command': ["sh", "-c", "sleep 5"] + }], + 'restartPolicy': 'Never'}, + 'metadata': {'name': name}}}, + 'apiVersion': 'batch/v1', + 'metadata': {'name': name}} + + resp = api.create_namespaced_job( + body=job_manifest, namespace='default') + self.assertEqual(name, resp.metadata.name) + + resp = api.read_namespaced_job( + name=name, namespace='default') + self.assertEqual(name, resp.metadata.name) + + resp = api.delete_namespaced_job( + name=name, body={}, namespace='default') \ No newline at end of file diff --git a/kubernetes/e2e_test/test_client.py b/kubernetes/e2e_test/test_client.py index 1569fb787..84f0ea6f6 100644 --- a/kubernetes/e2e_test/test_client.py +++ b/kubernetes/e2e_test/test_client.py @@ -12,95 +12,17 @@ # License for the specific language governing permissions and limitations # under the License. -""" -test_client ----------------------------------- - -Tests for `client` module. Deploy Kubernetes using: -http://kubernetes.io/docs/getting-started-guides/docker/ - -and then run this test -""" - import unittest -import urllib3 import uuid -import yaml from kubernetes.client import api_client from kubernetes.client.apis import core_v1_api -from kubernetes.client.apis import extensions_v1beta1_api - - -def _is_k8s_running(): - try: - urllib3.PoolManager().request('GET', '127.0.0.1:8080') - return True - except urllib3.exceptions.HTTPError: - return False +from kubernetes.e2e_test import base class TestClient(unittest.TestCase): @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") - def test_read_namespaces(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') - api = core_v1_api.CoreV1Api(client) - - expected_namespaces = ('default', 'kube-system') - for ns in expected_namespaces: - api.read_namespace(name=ns) - - @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") - def test_read_services(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') - api = core_v1_api.CoreV1Api(client) - - expected_services = ('kubernetes',) - for service in expected_services: - api.read_namespaced_service(service, 'default') - - @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") - def test_list_endpoints(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') - api = core_v1_api.CoreV1Api(client) - - endpoints = api.list_endpoints_for_all_namespaces() - self.assertTrue(len(endpoints.items) > 0) - - @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") - def test_create_deployment(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') - api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) - name = 'nginx-deployment-' + str(uuid.uuid4()) - deployment = '''apiVersion: extensions/v1beta1 -kind: Deployment -metadata: - name: %s -spec: - replicas: 3 - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.7.9 - ports: - - containerPort: 80 -''' - resp = api.create_namespaced_deployment( - body=yaml.load(deployment % name), - namespace="default") - resp = api.read_namespaced_deployment(name, 'default') - self.assertIsNotNone(resp) - - @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") + base.is_k8s_running(), "Kubernetes is not available") def test_pod_apis(self): client = api_client.ApiClient('http://127.0.0.1:8080/') api = core_v1_api.CoreV1Api(client) @@ -129,7 +51,7 @@ spec: namespace='default') @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") + base.is_k8s_running(), "Kubernetes is not available") def test_service_apis(self): client = api_client.ApiClient('http://127.0.0.1:8080/') api = core_v1_api.CoreV1Api(client) @@ -170,7 +92,7 @@ spec: namespace='default') @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") + base.is_k8s_running(), "Kubernetes is not available") def test_replication_controller_apis(self): client = api_client.ApiClient('http://127.0.0.1:8080/') api = core_v1_api.CoreV1Api(client) @@ -205,7 +127,7 @@ spec: name=name, body={}, namespace='default') @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") + base.is_k8s_running(), "Kubernetes is not available") def test_configmap_apis(self): client = api_client.ApiClient('http://127.0.0.1:8080/') api = core_v1_api.CoreV1Api(client) @@ -243,7 +165,7 @@ spec: self.assertEqual([], resp.items) @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") + base.is_k8s_running(), "Kubernetes is not available") def test_node_apis(self): client = api_client.ApiClient('http://127.0.0.1:8080/') api = core_v1_api.CoreV1Api(client) @@ -251,38 +173,4 @@ spec: for item in api.list_node().items: node = api.read_node(name=item.metadata.name) self.assertTrue(len(node.metadata.labels) > 0) - self.assertTrue(isinstance(node.metadata.labels, dict)) - - @unittest.skipUnless( - _is_k8s_running(), "Kubernetes is not available") - def test_create_daemonset(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') - api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) - name = 'nginx-app-' + str(uuid.uuid4()) - daemonset = { - 'apiVersion': 'extensions/v1beta1', - 'kind': 'DaemonSet', - 'metadata': { - 'labels': {'app': 'nginx'}, - 'name': '%s' % name, - }, - 'spec': { - 'template': { - 'metadata': { - 'labels': {'app': 'nginx'}, - 'name': name}, - 'spec': { - 'containers': [ - {'name': 'nginx-app', - 'image': 'nginx:1.10'}, - ], - }, - }, - 'updateStrategy': { - 'type': 'RollingUpdate', - }, - } - } - resp = api.create_namespaced_daemon_set('default', body=daemonset) - resp = api.read_namespaced_daemon_set(name, 'default') - self.assertIsNotNone(resp) \ No newline at end of file + self.assertTrue(isinstance(node.metadata.labels, dict)) \ No newline at end of file diff --git a/kubernetes/e2e_test/test_extensions.py b/kubernetes/e2e_test/test_extensions.py new file mode 100644 index 000000000..42030911a --- /dev/null +++ b/kubernetes/e2e_test/test_extensions.py @@ -0,0 +1,93 @@ +# -*- 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 unittest +import uuid +import yaml + +from kubernetes.client import api_client +from kubernetes.client.apis import extensions_v1beta1_api +from kubernetes.client.models import v1_delete_options +from kubernetes.e2e_test import base + + +class TestClientExtensions(unittest.TestCase): + @unittest.skipUnless( + base.is_k8s_running(), "Kubernetes is not available") + def test_create_deployment(self): + client = api_client.ApiClient('http://127.0.0.1:8080/') + api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) + name = 'nginx-deployment-' + str(uuid.uuid4()) + deployment = '''apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: %s +spec: + replicas: 3 + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.7.9 + ports: + - containerPort: 80 +''' + resp = api.create_namespaced_deployment( + body=yaml.load(deployment % name), + namespace="default") + resp = api.read_namespaced_deployment(name, 'default') + self.assertIsNotNone(resp) + + options = v1_delete_options.V1DeleteOptions() + resp = api.delete_namespaced_deployment(name, 'default', body=options) + + @unittest.skipUnless( + base.is_k8s_running(), "Kubernetes is not available") + def test_create_daemonset(self): + client = api_client.ApiClient('http://127.0.0.1:8080/') + api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) + name = 'nginx-app-' + str(uuid.uuid4()) + daemonset = { + 'apiVersion': 'extensions/v1beta1', + 'kind': 'DaemonSet', + 'metadata': { + 'labels': {'app': 'nginx'}, + 'name': '%s' % name, + }, + 'spec': { + 'template': { + 'metadata': { + 'labels': {'app': 'nginx'}, + 'name': name}, + 'spec': { + 'containers': [ + {'name': 'nginx-app', + 'image': 'nginx:1.10'}, + ], + }, + }, + 'updateStrategy': { + 'type': 'RollingUpdate', + }, + } + } + resp = api.create_namespaced_daemon_set('default', body=daemonset) + resp = api.read_namespaced_daemon_set(name, 'default') + self.assertIsNotNone(resp) + + options = v1_delete_options.V1DeleteOptions() + resp = api.delete_namespaced_daemon_set(name, 'default', body=options) \ No newline at end of file From 96cfceb2f039623d5827041c9b72e2a9cfb4ec9d Mon Sep 17 00:00:00 2001 From: Davanum Srinivas Date: Fri, 3 Feb 2017 09:52:40 -0500 Subject: [PATCH 07/12] Run e2e tests against against https url Use hitch tls proxy to listen on 8443 port and forward the traffic to 8080. Add a configuration option to ignore any issues with host names as we don't generate the cert on the fly. --- .travis.yml | 6 ++++ kubernetes/client/configuration.py | 3 ++ kubernetes/client/rest.py | 17 +++++++--- kubernetes/e2e_test/base.py | 11 +++++++ kubernetes/e2e_test/test_batch.py | 19 +++++++++-- kubernetes/e2e_test/test_client.py | 27 ++++++++++++---- kubernetes/e2e_test/test_extensions.py | 21 ++++++++++-- scripts/example.pem | 45 ++++++++++++++++++++++++++ tox.ini | 4 +-- 9 files changed, 135 insertions(+), 18 deletions(-) create mode 100755 scripts/example.pem diff --git a/.travis.yml b/.travis.yml index 17993ef14..cea3a5e76 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,8 +13,14 @@ env: - TOXENV=docs - TOXENV=coverage,codecov +before_install: + - sudo add-apt-repository ppa:0k53d-karl-f830m/hitch -y + - sudo apt-get -qq update + - sudo apt-get install hitch + install: - pip install tox + - hitch --frontend=[*]:8443 --backend=[localhost]:8080 --daemon $TRAVIS_BUILD_DIR/scripts/example.pem script: - tox diff --git a/kubernetes/client/configuration.py b/kubernetes/client/configuration.py index 10ec4decf..bf0fd7334 100644 --- a/kubernetes/client/configuration.py +++ b/kubernetes/client/configuration.py @@ -85,6 +85,9 @@ class ConfigurationObject(object): self.cert_file = None # client key file self.key_file = None + # check host name + # Set this to True/False to enable/disable SSL hostname verification. + self.assert_hostname = None @property def logger_file(self): diff --git a/kubernetes/client/rest.py b/kubernetes/client/rest.py index 2683b8f90..826d4467b 100644 --- a/kubernetes/client/rest.py +++ b/kubernetes/client/rest.py @@ -95,13 +95,20 @@ class RESTClientObject(object): # key file key_file = config.key_file + kwargs = { + 'num_pools': pools_size, + 'cert_reqs': cert_reqs, + 'ca_certs': ca_certs, + 'cert_file': cert_file, + 'key_file': key_file, + } + + if config.assert_hostname is not None: + kwargs['assert_hostname'] = config.assert_hostname + # https pool manager self.pool_manager = urllib3.PoolManager( - num_pools=pools_size, - cert_reqs=cert_reqs, - ca_certs=ca_certs, - cert_file=cert_file, - key_file=key_file + **kwargs ) def request(self, method, url, query_params=None, headers=None, diff --git a/kubernetes/e2e_test/base.py b/kubernetes/e2e_test/base.py index 84e5668e2..2205a9976 100644 --- a/kubernetes/e2e_test/base.py +++ b/kubernetes/e2e_test/base.py @@ -10,8 +10,11 @@ # License for the specific language governing permissions and limitations # under the License. +import copy +import os import urllib3 +from kubernetes.client.configuration import configuration def is_k8s_running(): try: @@ -19,3 +22,11 @@ def is_k8s_running(): return True except urllib3.exceptions.HTTPError: return False + + +def setSSLConfiguration(): + config = copy.copy(configuration) + config.verify_ssl = True + config.ssl_ca_cert = os.path.dirname(__file__) + '/../../scripts/example.pem' + config.assert_hostname = False + return config \ No newline at end of file diff --git a/kubernetes/e2e_test/test_batch.py b/kubernetes/e2e_test/test_batch.py index ac4b3fc6e..cb710baea 100644 --- a/kubernetes/e2e_test/test_batch.py +++ b/kubernetes/e2e_test/test_batch.py @@ -17,14 +17,21 @@ import uuid from kubernetes.client import api_client from kubernetes.client.apis import batch_v1_api +from kubernetes.client.configuration import configuration from kubernetes.e2e_test import base class TestClientBatch(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.API_URL = 'http://127.0.0.1:8080/' + cls.config = configuration + @unittest.skipUnless( base.is_k8s_running(), "Kubernetes is not available") def test_job_apis(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') + client = api_client.ApiClient(self.API_URL, config=self.config) api = batch_v1_api.BatchV1Api(client) name = 'test-job-' + str(uuid.uuid4()) @@ -52,4 +59,12 @@ class TestClientBatch(unittest.TestCase): self.assertEqual(name, resp.metadata.name) resp = api.delete_namespaced_job( - name=name, body={}, namespace='default') \ No newline at end of file + name=name, body={}, namespace='default') + + +class TestClientBatchSSL(TestClientBatch): + + @classmethod + def setUpClass(cls): + cls.API_URL = 'https://127.0.0.1:8443/' + cls.config = base.setSSLConfiguration() diff --git a/kubernetes/e2e_test/test_client.py b/kubernetes/e2e_test/test_client.py index 84f0ea6f6..a6ee3d6c4 100644 --- a/kubernetes/e2e_test/test_client.py +++ b/kubernetes/e2e_test/test_client.py @@ -17,14 +17,21 @@ import uuid from kubernetes.client import api_client from kubernetes.client.apis import core_v1_api +from kubernetes.client.configuration import configuration from kubernetes.e2e_test import base class TestClient(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.API_URL = 'http://127.0.0.1:8080/' + cls.config = configuration + @unittest.skipUnless( base.is_k8s_running(), "Kubernetes is not available") def test_pod_apis(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') + client = api_client.ApiClient(self.API_URL, config=self.config) api = core_v1_api.CoreV1Api(client) name = 'test-' + str(uuid.uuid4()) @@ -53,7 +60,7 @@ class TestClient(unittest.TestCase): @unittest.skipUnless( base.is_k8s_running(), "Kubernetes is not available") def test_service_apis(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') + client = api_client.ApiClient(self.API_URL, config=self.config) api = core_v1_api.CoreV1Api(client) name = 'frontend-' + str(uuid.uuid4()) @@ -94,7 +101,7 @@ class TestClient(unittest.TestCase): @unittest.skipUnless( base.is_k8s_running(), "Kubernetes is not available") def test_replication_controller_apis(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') + client = api_client.ApiClient(self.API_URL, config=self.config) api = core_v1_api.CoreV1Api(client) name = 'frontend-' + str(uuid.uuid4()) @@ -129,7 +136,7 @@ class TestClient(unittest.TestCase): @unittest.skipUnless( base.is_k8s_running(), "Kubernetes is not available") def test_configmap_apis(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') + client = api_client.ApiClient(self.API_URL, config=self.config) api = core_v1_api.CoreV1Api(client) name = 'test-configmap-' + str(uuid.uuid4()) @@ -167,10 +174,18 @@ class TestClient(unittest.TestCase): @unittest.skipUnless( base.is_k8s_running(), "Kubernetes is not available") def test_node_apis(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') + client = api_client.ApiClient(self.API_URL, config=self.config) api = core_v1_api.CoreV1Api(client) for item in api.list_node().items: node = api.read_node(name=item.metadata.name) self.assertTrue(len(node.metadata.labels) > 0) - self.assertTrue(isinstance(node.metadata.labels, dict)) \ No newline at end of file + self.assertTrue(isinstance(node.metadata.labels, dict)) + + +class TestClientSSL(TestClient): + + @classmethod + def setUpClass(cls): + cls.API_URL = 'https://127.0.0.1:8443/' + cls.config = base.setSSLConfiguration() diff --git a/kubernetes/e2e_test/test_extensions.py b/kubernetes/e2e_test/test_extensions.py index 42030911a..69da8b8cf 100644 --- a/kubernetes/e2e_test/test_extensions.py +++ b/kubernetes/e2e_test/test_extensions.py @@ -18,15 +18,22 @@ import yaml from kubernetes.client import api_client from kubernetes.client.apis import extensions_v1beta1_api +from kubernetes.client.configuration import configuration from kubernetes.client.models import v1_delete_options from kubernetes.e2e_test import base class TestClientExtensions(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.API_URL = 'http://127.0.0.1:8080/' + cls.config = configuration + @unittest.skipUnless( base.is_k8s_running(), "Kubernetes is not available") def test_create_deployment(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') + client = api_client.ApiClient(self.API_URL, config=self.config) api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) name = 'nginx-deployment-' + str(uuid.uuid4()) deployment = '''apiVersion: extensions/v1beta1 @@ -58,7 +65,7 @@ spec: @unittest.skipUnless( base.is_k8s_running(), "Kubernetes is not available") def test_create_daemonset(self): - client = api_client.ApiClient('http://127.0.0.1:8080/') + client = api_client.ApiClient(self.API_URL, config=self.config) api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) name = 'nginx-app-' + str(uuid.uuid4()) daemonset = { @@ -90,4 +97,12 @@ spec: self.assertIsNotNone(resp) options = v1_delete_options.V1DeleteOptions() - resp = api.delete_namespaced_daemon_set(name, 'default', body=options) \ No newline at end of file + resp = api.delete_namespaced_daemon_set(name, 'default', body=options) + + +class TestClientExtensionsSSL(TestClientExtensions): + + @classmethod + def setUpClass(cls): + cls.API_URL = 'https://127.0.0.1:8443/' + cls.config = base.setSSLConfiguration() diff --git a/scripts/example.pem b/scripts/example.pem new file mode 100755 index 000000000..5031637b8 --- /dev/null +++ b/scripts/example.pem @@ -0,0 +1,45 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC322Zo7ETx0bAw +0kJytMDPa76VT+TRgZj6AzS8xm+kRXeqLDJ+6ZmDvZkNKwbwIKAmccWvY/OJNv/0 +c5f1Hd6Y+vK9Qxi4f62ZbavKJpcxIFepa/hmrkRN0Iw9ZahzRZy07jw9SFxZTKEK +GTj/sb+SCUMwaJFZN0D6zhtqR70NjdHp14JWJsUtarBBtoGzEtINJgRkTS9ej6Fj +bh2RGz6HKiWQgX9W7v5P7zFek9IUDEczBr/aQlFXwE0tNUMjbZyMM4TfgITcx+mj +FbR3+hoZLJg0NMKT2wSiKvp1DU0KR5xF8S4q4OUC5yyvV2ylHPdvWldh+6LXcrx/ +oSxhpYDhAgMBAAECggEAUNZfhbx0Z9ppXF3mJ2b/63MVHbM+CTuxFiP4uROKnLCK +d8DtBs4Q2FKxi4+igkvl/mFBqOcKegc7rLByXKygZaTYu4xXvy8sFeyZfs1O5qOw +x2YYlpUCpTAPqSMcWGqABzFEPTGmoQDHQZhrbkkp0LzP1OX1GkPoBx4+AZG/Nsin +aWrTgfPNOtK2RGyLuS3rNn+NWh1jlm/37AVayKxSTirL5XXZUOW3Yye5ROZDWddr +rKzkhIsF/zcUxsQvFtMtjFPRFhKlasAx6MgPB2ptzj5Ykq29jumVfBd9O6voqDMW +ZFnN7G/wjLz8RM9hyW6hBLwIJV4ybJ1DagwqhGNzUQKBgQDxVQOsIWrHkxwZXA8a +iVJDpGlYc6jPlam2T2m3yXPqXfXlZ7Qx+RcmYY94QdEgeF1DGI8xNc1PSiSKjWH0 ++c3jbabB6kk82Qi2RzbApnjQdzlnWv29jiRgPVgPZcoSiMQFmtG8pUFgI8zOCsQK +1iZTgx6KxMpZpo4xSZiBPR2mzQKBgQDDCBuIjPYQwG4MPTyTyorvsCaZmxkLgFXd +nBhPFtjVAUuLamoche27VXdFgTpYRF8EflIyeSQ3+Dkr8tceMkZaX4Ih3pKEsMxI +AZALSVBp0Hdz06RGsqc5dPU8N0asNvEZfoNhTBJ0cD/TYABOg3PQyPr7Ez5Y/SdR +UYaG30l6ZQKBgAaljcVW4kb+4T49j9juQUrFo3UhMlwNRjBUPZgnPz8MOXKJCah6 +sM2I0FfCkEzxo7fuXDtBvRba9uit/i2uF6KU6YvbtQqs+5VxnqttqlQrhHQ5SFXJ +LW1NIzjBV/BsveFdozsr3gIU2lYua7nUrheMu/Gce+o+MRpgaYfdtAxdAoGBAJAz +RmhIEQeBv9w8yrVbZC6kR2X7TyE52kLoTvDrK5cSRhDmtV4xh/yizHUPf1wT8U0Z +OR0ohKb9WQgtnPAuq+XWCBmSvzJsph33SdGOe25BPJDfQu8i2JGa8Fd9Zzudw9Xd +vLYL0PlWpVpb+N4UQ2VztF4/dDHHu3JcnOLL5UAhAoGBAJ26mvFsFi4iznYHaK7l +duuJtFHkfi3OQhNQN8PBPu4bat+WL5GA3QhGbdLYJXNse5BbytWeG0gw6TY8SYYV +KJgaBxUrGyVF6DBb7Bef5I+YKFu3Q30gzXhyUudC767AJ8DaEudTObjdKWjJlPBG +T4ouTQt/t6W+er9GlqaLpKCw +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIICuDCCAaCgAwIBAgIJAOUAihuiFPxaMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0xNzAyMDIyMTQ0MTVaFw0yNzAxMzEyMTQ0MTVaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALfbZmjsRPHRsDDSQnK0wM9rvpVP5NGBmPoDNLzGb6RFd6osMn7pmYO9mQ0r +BvAgoCZxxa9j84k2//Rzl/Ud3pj68r1DGLh/rZltq8omlzEgV6lr+GauRE3QjD1l +qHNFnLTuPD1IXFlMoQoZOP+xv5IJQzBokVk3QPrOG2pHvQ2N0enXglYmxS1qsEG2 +gbMS0g0mBGRNL16PoWNuHZEbPocqJZCBf1bu/k/vMV6T0hQMRzMGv9pCUVfATS01 +QyNtnIwzhN+AhNzH6aMVtHf6GhksmDQ0wpPbBKIq+nUNTQpHnEXxLirg5QLnLK9X +bKUc929aV2H7otdyvH+hLGGlgOECAwEAAaMNMAswCQYDVR0TBAIwADANBgkqhkiG +9w0BAQsFAAOCAQEABblz/REaCmzZq/wlRN3NdwRuLvSz1peAVQNmuEfpIsYDxHIU +ognnm+afEo6O18PjBXFSP4r1vsc/TTGk1T3xP4FgPJ9xLsUNQk9Kch05vQIwJtcQ +iIdMRhGVdxSg8V29KTFImfcbS/VkV9Ev/FKHifs+PL9rJMBpE/r6xe6D6p+d9jw5 +cpCw+kgGHZVWA+8GEjyCGZIHyMAL6YwC246N6uTPuDHyvQZZHqh9r602bp5zpMbw +ZW4+YD7+PEAhFmTRYiqUPTyBPRBKcIZdkKtND/CQ4IwtHJ+ApjwQuXBjKUpPJroh +s5cwhxeaimBe9C9axIuuUd8LAVTXLFVwL0wEYw== +-----END CERTIFICATE----- diff --git a/tox.ini b/tox.ini index c9e8aee09..bfb56cf07 100644 --- a/tox.ini +++ b/tox.ini @@ -21,12 +21,12 @@ commands = [testenv:py27-functional] commands = python -V - {toxinidir}/scripts/kube-init.sh nosetests [] + {toxinidir}/scripts/kube-init.sh nosetests -v [] [testenv:py35-functional] commands = python -V - {toxinidir}/scripts/kube-init.sh nosetests [] + {toxinidir}/scripts/kube-init.sh nosetests -v [] [testenv:coverage] commands = From 16d6da465eef537fe81719e4bafdad0c41e9a301 Mon Sep 17 00:00:00 2001 From: Davanum Srinivas Date: Tue, 7 Feb 2017 16:00:33 -0500 Subject: [PATCH 08/12] Add comment about hitch ppa --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index cea3a5e76..f67547b75 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,9 @@ env: - TOXENV=docs - TOXENV=coverage,codecov +# We use hitch (https://hitch-tls.org/) to setup TLS proxy from 8443 to 8080. while hitch is +# in the ubuntu xenial main repos, it's not available by default on trusty. So we use the +# ppa from here : https://launchpad.net/~0k53d-karl-f830m/+archive/ubuntu/hitch before_install: - sudo add-apt-repository ppa:0k53d-karl-f830m/hitch -y - sudo apt-get -qq update From 066bba1802797b1d7895854ec6fdb51f5a9d8823 Mon Sep 17 00:00:00 2001 From: Davanum Srinivas Date: Wed, 1 Feb 2017 12:34:36 -0500 Subject: [PATCH 09/12] Implementation for /exec using websocket inspired by the POC from @chekolyn * Adds a new requirement on websocket-client * Add a new class WSClient that uses WebSocketApp from the websocket-client. * Make sure we pass Authorization header * Make sure we honor the SSL settings in configuration * Some of the code will get overwritten when we generate fresh classes from swagger definition. To remind us added a e2e test so we don't lose the changes * Added a new configuration option to enable/disable failures when hostnames in certificates don't match Fixes #58 --- kubernetes/client/api_client.py | 10 +++ kubernetes/client/ws_client.py | 114 +++++++++++++++++++++++++++++ kubernetes/e2e_test/test_client.py | 48 +++++++++--- requirements.txt | 4 +- tox.ini | 1 + 5 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 kubernetes/client/ws_client.py diff --git a/kubernetes/client/api_client.py b/kubernetes/client/api_client.py index d553aef6f..c198b15d5 100644 --- a/kubernetes/client/api_client.py +++ b/kubernetes/client/api_client.py @@ -21,6 +21,7 @@ Copyright 2016 SmartBear Software from __future__ import absolute_import from . import models +from . import ws_client from .rest import RESTClientObject from .rest import ApiException @@ -343,6 +344,15 @@ class ApiClient(object): """ Makes the HTTP request using RESTClient. """ + # FIXME(dims) : We need a better way to figure out which + # calls end up using web sockets + if url.endswith('/exec') and method == "GET": + return ws_client.GET(self.config, + url, + query_params=query_params, + _request_timeout=_request_timeout, + headers=headers) + if method == "GET": return self.rest_client.GET(url, query_params=query_params, diff --git a/kubernetes/client/ws_client.py b/kubernetes/client/ws_client.py new file mode 100644 index 000000000..b143400be --- /dev/null +++ b/kubernetes/client/ws_client.py @@ -0,0 +1,114 @@ +# 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 .rest import ApiException + +import certifi +import collections +import websocket +import six +import ssl +from six.moves.urllib.parse import urlencode +from six.moves.urllib.parse import quote_plus + + +class WSClient: + def __init__(self, configuration, url, headers): + self.messages = [] + self.errors = [] + websocket.enableTrace(False) + header = None + + # We just need to pass the Authorization, ignore all the other + # http headers we get from the generated code + if 'Authorization' in headers: + header = "Authorization: %s" % headers['Authorization'] + + self.ws = websocket.WebSocketApp(url, + on_message=self.on_message, + on_error=self.on_error, + on_close=self.on_close, + header=[header] if header else None) + self.ws.on_open = self.on_open + + if url.startswith('wss://') and configuration.verify_ssl: + ssl_opts = { + 'cert_reqs': ssl.CERT_REQUIRED, + 'keyfile': configuration.key_file, + 'certfile': configuration.cert_file, + '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} + + self.ws.run_forever(sslopt=ssl_opts) + + def on_message(self, ws, message): + if message[0] == '\x01': + message = message[1:] + if message: + if six.PY3 and isinstance(message, six.binary_type): + message = message.decode('utf-8') + self.messages.append(message) + + def on_error(self, ws, error): + self.errors.append(error) + + def on_close(self, ws): + pass + + def on_open(self, ws): + pass + + +WSResponse = collections.namedtuple('WSResponse', ['data']) + + +def GET(configuration, url, query_params, _request_timeout, headers): + # switch protocols from http to websocket + url = url.replace('http://', 'ws://') + url = url.replace('https://', 'wss://') + + # patch extra / + url = url.replace('//api', '/api') + + # Extract the command from the list of tuples + commands = None + for key, value in query_params: + if key == 'command': + commands = value + break + + # drop command from query_params as we will be processing it separately + query_params = [(key, value) for key, value in query_params if + key != 'command'] + + # if we still have query params then encode them + if query_params: + url += '?' + urlencode(query_params) + + # tack on the actual command to execute at the end + if isinstance(commands, list): + for command in commands: + url += "&command=%s&" % quote_plus(command) + else: + url += '&command=' + quote_plus(commands) + + client = WSClient(configuration, url, headers) + if client.errors: + raise ApiException( + status=0, + reason='\n'.join([str(error) for error in client.errors]) + ) + return WSResponse('%s' % ''.join(client.messages)) diff --git a/kubernetes/e2e_test/test_client.py b/kubernetes/e2e_test/test_client.py index a6ee3d6c4..ec2792987 100644 --- a/kubernetes/e2e_test/test_client.py +++ b/kubernetes/e2e_test/test_client.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +import time import unittest import uuid @@ -34,22 +35,49 @@ class TestClient(unittest.TestCase): client = api_client.ApiClient(self.API_URL, config=self.config) api = core_v1_api.CoreV1Api(client) - name = 'test-' + str(uuid.uuid4()) - pod_manifest = {'apiVersion': 'v1', - 'kind': 'Pod', - 'metadata': {'color': 'blue', 'name': name}, - 'spec': {'containers': [{'image': 'dockerfile/redis', - 'name': 'redis'}]}} + name = 'busybox-test-' + str(uuid.uuid4()) + pod_manifest = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': { + 'name': name + }, + 'spec': { + 'containers': [{ + 'image': 'busybox', + 'name': 'sleep', + "args": [ + "/bin/sh", + "-c", + "while true;do date;sleep 5; done" + ] + }] + } + } resp = api.create_namespaced_pod(body=pod_manifest, namespace='default') self.assertEqual(name, resp.metadata.name) self.assertTrue(resp.status.phase) - resp = api.read_namespaced_pod(name=name, - namespace='default') - self.assertEqual(name, resp.metadata.name) - self.assertTrue(resp.status.phase) + while True: + resp = api.read_namespaced_pod(name=name, + namespace='default') + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status.phase) + if resp.status.phase != 'Pending': + break + time.sleep(1) + + exec_command = ['/bin/sh', + '-c', + 'for i in $(seq 1 3); do date; sleep 1; done'] + resp = api.connect_get_namespaced_pod_exec(name, 'default', + command=exec_command, + stderr=False, stdin=False, + stdout=True, tty=False) + print('EXEC response : %s' % resp) + self.assertEqual(3, len(resp.splitlines())) number_of_pods = len(api.list_pod_for_all_namespaces().items) self.assertTrue(number_of_pods > 0) diff --git a/requirements.txt b/requirements.txt index 49d541add..2674d78ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ certifi >= 14.05.14 -six == 1.8.0 +six>=1.9.0 python_dateutil >= 2.5.3 setuptools >= 21.0.0 urllib3 >= 1.19.1 pyyaml >= 3.12 oauth2client >= 4.0.0 ipaddress >= 1.0.17 - +websocket-client>=0.32.0 diff --git a/tox.ini b/tox.ini index bfb56cf07..4091bcae6 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ passenv = TOXENV CI TRAVIS TRAVIS_* usedevelop = True install_command = pip install -U {opts} {packages} deps = -r{toxinidir}/test-requirements.txt + -r{toxinidir}/requirements.txt commands = python -V nosetests [] From 353e8ccdc8b8ffac6fbdbeb1d508e7efa497f2c8 Mon Sep 17 00:00:00 2001 From: Davanum Srinivas Date: Thu, 9 Feb 2017 13:28:49 -0500 Subject: [PATCH 10/12] Switch to minikube/localkube * Drop the old Kubernetes on docker containers method as it did not have a SSL enabled port * Use localkube component from minikube instead. This enables us to drop the hitch TLS proxy as well. * E2E tests should be easy to run locally and pick up configuration from ~/.kube/config * Consolidate the urls in one spot (base.py), also consolidate the SkipTest in one spot. * For local testing, just run minikube and run the py27 or py34 tox target, that should run all the tests including the e2e tests. * Fix the connect_post_namespaced_pod_exec and add a e2e test for it Fixes #122 --- .travis.yml | 9 --- kubernetes/client/api_client.py | 2 +- kubernetes/e2e_test/base.py | 35 ++++++--- kubernetes/e2e_test/test_batch.py | 19 +---- kubernetes/e2e_test/test_client.py | 41 ++++------ kubernetes/e2e_test/test_extensions.py | 21 +---- scripts/example.pem | 45 ----------- scripts/kube-init.sh | 101 +++++++++---------------- 8 files changed, 85 insertions(+), 188 deletions(-) delete mode 100755 scripts/example.pem diff --git a/.travis.yml b/.travis.yml index f67547b75..17993ef14 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,17 +13,8 @@ env: - TOXENV=docs - TOXENV=coverage,codecov -# We use hitch (https://hitch-tls.org/) to setup TLS proxy from 8443 to 8080. while hitch is -# in the ubuntu xenial main repos, it's not available by default on trusty. So we use the -# ppa from here : https://launchpad.net/~0k53d-karl-f830m/+archive/ubuntu/hitch -before_install: - - sudo add-apt-repository ppa:0k53d-karl-f830m/hitch -y - - sudo apt-get -qq update - - sudo apt-get install hitch - install: - pip install tox - - hitch --frontend=[*]:8443 --backend=[localhost]:8080 --daemon $TRAVIS_BUILD_DIR/scripts/example.pem script: - tox diff --git a/kubernetes/client/api_client.py b/kubernetes/client/api_client.py index c198b15d5..6dbe7137d 100644 --- a/kubernetes/client/api_client.py +++ b/kubernetes/client/api_client.py @@ -346,7 +346,7 @@ class ApiClient(object): """ # FIXME(dims) : We need a better way to figure out which # calls end up using web sockets - if url.endswith('/exec') and method == "GET": + if url.endswith('/exec') and (method == "GET" or method == "POST"): return ws_client.GET(self.config, url, query_params=query_params, diff --git a/kubernetes/e2e_test/base.py b/kubernetes/e2e_test/base.py index 2205a9976..5f04ab7e8 100644 --- a/kubernetes/e2e_test/base.py +++ b/kubernetes/e2e_test/base.py @@ -12,21 +12,34 @@ import copy import os +import unittest import urllib3 from kubernetes.client.configuration import configuration +from kubernetes.config import kube_config -def is_k8s_running(): - try: - urllib3.PoolManager().request('GET', '127.0.0.1:8080') - return True - except urllib3.exceptions.HTTPError: - return False +DEFAULT_E2E_HOST = '127.0.0.1' -def setSSLConfiguration(): +def get_e2e_configuration(): config = copy.copy(configuration) - config.verify_ssl = True - config.ssl_ca_cert = os.path.dirname(__file__) + '/../../scripts/example.pem' - config.assert_hostname = False - return config \ No newline at end of file + config.host = None + if os.path.exists( + os.path.expanduser(kube_config.KUBE_CONFIG_DEFAULT_LOCATION)): + kube_config.load_kube_config(client_configuration=config) + else: + print('Unable to load config from %s' % + kube_config.KUBE_CONFIG_DEFAULT_LOCATION) + for url in ['https://%s:8443' % DEFAULT_E2E_HOST, + 'http://%s:8080' % DEFAULT_E2E_HOST]: + try: + urllib3.PoolManager().request('GET', url) + config.host = url + config.verify_ssl = False + break + except urllib3.exceptions.HTTPError: + pass + if config.host is None: + raise unittest.SkipTest('Unable to find a running Kubernetes instance') + print('Running test against : %s' % config.host) + return config diff --git a/kubernetes/e2e_test/test_batch.py b/kubernetes/e2e_test/test_batch.py index cb710baea..14800556b 100644 --- a/kubernetes/e2e_test/test_batch.py +++ b/kubernetes/e2e_test/test_batch.py @@ -17,7 +17,6 @@ import uuid from kubernetes.client import api_client from kubernetes.client.apis import batch_v1_api -from kubernetes.client.configuration import configuration from kubernetes.e2e_test import base @@ -25,13 +24,11 @@ class TestClientBatch(unittest.TestCase): @classmethod def setUpClass(cls): - cls.API_URL = 'http://127.0.0.1:8080/' - cls.config = configuration + cls.config = base.get_e2e_configuration() + - @unittest.skipUnless( - base.is_k8s_running(), "Kubernetes is not available") def test_job_apis(self): - client = api_client.ApiClient(self.API_URL, config=self.config) + client = api_client.ApiClient(config=self.config) api = batch_v1_api.BatchV1Api(client) name = 'test-job-' + str(uuid.uuid4()) @@ -59,12 +56,4 @@ class TestClientBatch(unittest.TestCase): self.assertEqual(name, resp.metadata.name) resp = api.delete_namespaced_job( - name=name, body={}, namespace='default') - - -class TestClientBatchSSL(TestClientBatch): - - @classmethod - def setUpClass(cls): - cls.API_URL = 'https://127.0.0.1:8443/' - cls.config = base.setSSLConfiguration() + name=name, body={}, namespace='default') \ No newline at end of file diff --git a/kubernetes/e2e_test/test_client.py b/kubernetes/e2e_test/test_client.py index ec2792987..8bc9b3d30 100644 --- a/kubernetes/e2e_test/test_client.py +++ b/kubernetes/e2e_test/test_client.py @@ -26,13 +26,10 @@ class TestClient(unittest.TestCase): @classmethod def setUpClass(cls): - cls.API_URL = 'http://127.0.0.1:8080/' - cls.config = configuration + cls.config = base.get_e2e_configuration() - @unittest.skipUnless( - base.is_k8s_running(), "Kubernetes is not available") def test_pod_apis(self): - client = api_client.ApiClient(self.API_URL, config=self.config) + client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) name = 'busybox-test-' + str(uuid.uuid4()) @@ -79,16 +76,22 @@ class TestClient(unittest.TestCase): print('EXEC response : %s' % resp) self.assertEqual(3, len(resp.splitlines())) + exec_command = 'uptime' + resp = api.connect_post_namespaced_pod_exec(name, 'default', + command=exec_command, + stderr=False, stdin=False, + stdout=True, tty=False) + print('EXEC response : %s' % resp) + self.assertEqual(1, len(resp.splitlines())) + number_of_pods = len(api.list_pod_for_all_namespaces().items) self.assertTrue(number_of_pods > 0) resp = api.delete_namespaced_pod(name=name, body={}, namespace='default') - @unittest.skipUnless( - base.is_k8s_running(), "Kubernetes is not available") def test_service_apis(self): - client = api_client.ApiClient(self.API_URL, config=self.config) + client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) name = 'frontend-' + str(uuid.uuid4()) @@ -126,10 +129,8 @@ class TestClient(unittest.TestCase): resp = api.delete_namespaced_service(name=name, namespace='default') - @unittest.skipUnless( - base.is_k8s_running(), "Kubernetes is not available") def test_replication_controller_apis(self): - client = api_client.ApiClient(self.API_URL, config=self.config) + client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) name = 'frontend-' + str(uuid.uuid4()) @@ -161,10 +162,8 @@ class TestClient(unittest.TestCase): resp = api.delete_namespaced_replication_controller( name=name, body={}, namespace='default') - @unittest.skipUnless( - base.is_k8s_running(), "Kubernetes is not available") def test_configmap_apis(self): - client = api_client.ApiClient(self.API_URL, config=self.config) + client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) name = 'test-configmap-' + str(uuid.uuid4()) @@ -199,21 +198,11 @@ class TestClient(unittest.TestCase): resp = api.list_namespaced_config_map('kube-system', pretty=True) self.assertEqual([], resp.items) - @unittest.skipUnless( - base.is_k8s_running(), "Kubernetes is not available") def test_node_apis(self): - client = api_client.ApiClient(self.API_URL, config=self.config) + client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) for item in api.list_node().items: node = api.read_node(name=item.metadata.name) self.assertTrue(len(node.metadata.labels) > 0) - self.assertTrue(isinstance(node.metadata.labels, dict)) - - -class TestClientSSL(TestClient): - - @classmethod - def setUpClass(cls): - cls.API_URL = 'https://127.0.0.1:8443/' - cls.config = base.setSSLConfiguration() + self.assertTrue(isinstance(node.metadata.labels, dict)) \ No newline at end of file diff --git a/kubernetes/e2e_test/test_extensions.py b/kubernetes/e2e_test/test_extensions.py index 69da8b8cf..d079410be 100644 --- a/kubernetes/e2e_test/test_extensions.py +++ b/kubernetes/e2e_test/test_extensions.py @@ -27,13 +27,10 @@ class TestClientExtensions(unittest.TestCase): @classmethod def setUpClass(cls): - cls.API_URL = 'http://127.0.0.1:8080/' - cls.config = configuration + cls.config = base.get_e2e_configuration() - @unittest.skipUnless( - base.is_k8s_running(), "Kubernetes is not available") def test_create_deployment(self): - client = api_client.ApiClient(self.API_URL, config=self.config) + client = api_client.ApiClient(config=self.config) api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) name = 'nginx-deployment-' + str(uuid.uuid4()) deployment = '''apiVersion: extensions/v1beta1 @@ -62,10 +59,8 @@ spec: options = v1_delete_options.V1DeleteOptions() resp = api.delete_namespaced_deployment(name, 'default', body=options) - @unittest.skipUnless( - base.is_k8s_running(), "Kubernetes is not available") def test_create_daemonset(self): - client = api_client.ApiClient(self.API_URL, config=self.config) + client = api_client.ApiClient(config=self.config) api = extensions_v1beta1_api.ExtensionsV1beta1Api(client) name = 'nginx-app-' + str(uuid.uuid4()) daemonset = { @@ -97,12 +92,4 @@ spec: self.assertIsNotNone(resp) options = v1_delete_options.V1DeleteOptions() - resp = api.delete_namespaced_daemon_set(name, 'default', body=options) - - -class TestClientExtensionsSSL(TestClientExtensions): - - @classmethod - def setUpClass(cls): - cls.API_URL = 'https://127.0.0.1:8443/' - cls.config = base.setSSLConfiguration() + resp = api.delete_namespaced_daemon_set(name, 'default', body=options) \ No newline at end of file diff --git a/scripts/example.pem b/scripts/example.pem deleted file mode 100755 index 5031637b8..000000000 --- a/scripts/example.pem +++ /dev/null @@ -1,45 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC322Zo7ETx0bAw -0kJytMDPa76VT+TRgZj6AzS8xm+kRXeqLDJ+6ZmDvZkNKwbwIKAmccWvY/OJNv/0 -c5f1Hd6Y+vK9Qxi4f62ZbavKJpcxIFepa/hmrkRN0Iw9ZahzRZy07jw9SFxZTKEK -GTj/sb+SCUMwaJFZN0D6zhtqR70NjdHp14JWJsUtarBBtoGzEtINJgRkTS9ej6Fj -bh2RGz6HKiWQgX9W7v5P7zFek9IUDEczBr/aQlFXwE0tNUMjbZyMM4TfgITcx+mj -FbR3+hoZLJg0NMKT2wSiKvp1DU0KR5xF8S4q4OUC5yyvV2ylHPdvWldh+6LXcrx/ -oSxhpYDhAgMBAAECggEAUNZfhbx0Z9ppXF3mJ2b/63MVHbM+CTuxFiP4uROKnLCK -d8DtBs4Q2FKxi4+igkvl/mFBqOcKegc7rLByXKygZaTYu4xXvy8sFeyZfs1O5qOw -x2YYlpUCpTAPqSMcWGqABzFEPTGmoQDHQZhrbkkp0LzP1OX1GkPoBx4+AZG/Nsin -aWrTgfPNOtK2RGyLuS3rNn+NWh1jlm/37AVayKxSTirL5XXZUOW3Yye5ROZDWddr -rKzkhIsF/zcUxsQvFtMtjFPRFhKlasAx6MgPB2ptzj5Ykq29jumVfBd9O6voqDMW -ZFnN7G/wjLz8RM9hyW6hBLwIJV4ybJ1DagwqhGNzUQKBgQDxVQOsIWrHkxwZXA8a -iVJDpGlYc6jPlam2T2m3yXPqXfXlZ7Qx+RcmYY94QdEgeF1DGI8xNc1PSiSKjWH0 -+c3jbabB6kk82Qi2RzbApnjQdzlnWv29jiRgPVgPZcoSiMQFmtG8pUFgI8zOCsQK -1iZTgx6KxMpZpo4xSZiBPR2mzQKBgQDDCBuIjPYQwG4MPTyTyorvsCaZmxkLgFXd -nBhPFtjVAUuLamoche27VXdFgTpYRF8EflIyeSQ3+Dkr8tceMkZaX4Ih3pKEsMxI -AZALSVBp0Hdz06RGsqc5dPU8N0asNvEZfoNhTBJ0cD/TYABOg3PQyPr7Ez5Y/SdR -UYaG30l6ZQKBgAaljcVW4kb+4T49j9juQUrFo3UhMlwNRjBUPZgnPz8MOXKJCah6 -sM2I0FfCkEzxo7fuXDtBvRba9uit/i2uF6KU6YvbtQqs+5VxnqttqlQrhHQ5SFXJ -LW1NIzjBV/BsveFdozsr3gIU2lYua7nUrheMu/Gce+o+MRpgaYfdtAxdAoGBAJAz -RmhIEQeBv9w8yrVbZC6kR2X7TyE52kLoTvDrK5cSRhDmtV4xh/yizHUPf1wT8U0Z -OR0ohKb9WQgtnPAuq+XWCBmSvzJsph33SdGOe25BPJDfQu8i2JGa8Fd9Zzudw9Xd -vLYL0PlWpVpb+N4UQ2VztF4/dDHHu3JcnOLL5UAhAoGBAJ26mvFsFi4iznYHaK7l -duuJtFHkfi3OQhNQN8PBPu4bat+WL5GA3QhGbdLYJXNse5BbytWeG0gw6TY8SYYV -KJgaBxUrGyVF6DBb7Bef5I+YKFu3Q30gzXhyUudC767AJ8DaEudTObjdKWjJlPBG -T4ouTQt/t6W+er9GlqaLpKCw ------END PRIVATE KEY----- ------BEGIN CERTIFICATE----- -MIICuDCCAaCgAwIBAgIJAOUAihuiFPxaMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV -BAMMCWxvY2FsaG9zdDAeFw0xNzAyMDIyMTQ0MTVaFw0yNzAxMzEyMTQ0MTVaMBQx -EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC -ggEBALfbZmjsRPHRsDDSQnK0wM9rvpVP5NGBmPoDNLzGb6RFd6osMn7pmYO9mQ0r -BvAgoCZxxa9j84k2//Rzl/Ud3pj68r1DGLh/rZltq8omlzEgV6lr+GauRE3QjD1l -qHNFnLTuPD1IXFlMoQoZOP+xv5IJQzBokVk3QPrOG2pHvQ2N0enXglYmxS1qsEG2 -gbMS0g0mBGRNL16PoWNuHZEbPocqJZCBf1bu/k/vMV6T0hQMRzMGv9pCUVfATS01 -QyNtnIwzhN+AhNzH6aMVtHf6GhksmDQ0wpPbBKIq+nUNTQpHnEXxLirg5QLnLK9X -bKUc929aV2H7otdyvH+hLGGlgOECAwEAAaMNMAswCQYDVR0TBAIwADANBgkqhkiG -9w0BAQsFAAOCAQEABblz/REaCmzZq/wlRN3NdwRuLvSz1peAVQNmuEfpIsYDxHIU -ognnm+afEo6O18PjBXFSP4r1vsc/TTGk1T3xP4FgPJ9xLsUNQk9Kch05vQIwJtcQ -iIdMRhGVdxSg8V29KTFImfcbS/VkV9Ev/FKHifs+PL9rJMBpE/r6xe6D6p+d9jw5 -cpCw+kgGHZVWA+8GEjyCGZIHyMAL6YwC246N6uTPuDHyvQZZHqh9r602bp5zpMbw -ZW4+YD7+PEAhFmTRYiqUPTyBPRBKcIZdkKtND/CQ4IwtHJ+ApjwQuXBjKUpPJroh -s5cwhxeaimBe9C9axIuuUd8LAVTXLFVwL0wEYw== ------END CERTIFICATE----- diff --git a/scripts/kube-init.sh b/scripts/kube-init.sh index 1348fa02b..66c7f3c0f 100755 --- a/scripts/kube-init.sh +++ b/scripts/kube-init.sh @@ -20,7 +20,7 @@ function clean_exit(){ local error_code="$?" local spawned=$(jobs -p) if [ -n "$spawned" ]; then - kill $(jobs -p) + sudo kill $(jobs -p) fi return $error_code } @@ -49,75 +49,48 @@ sudo systemctl start docker.service --ignore-dependencies echo "Checking docker service" sudo docker ps -# Run the docker containers for kubernetes -echo "Starting Kubernetes containers" -sudo docker run \ - --volume=/:/rootfs:ro \ - --volume=/sys:/sys:ro \ - --volume=/var/lib/docker/:/var/lib/docker:rw \ - --volume=/var/lib/kubelet/:/var/lib/kubelet:rw \ - --volume=/var/run:/var/run:rw \ - --net=host \ - --pid=host \ - --privileged=true \ - --name=kubelet \ - -d \ - gcr.io/google_containers/hyperkube-amd64:${K8S_VERSION} \ - /hyperkube kubelet \ - --containerized \ - --hostname-override="127.0.0.1" \ - --address="0.0.0.0" \ - --api-servers=http://localhost:8080 \ - --config=/etc/kubernetes/manifests \ - --allow-privileged=true --v=2 - - echo "Download Kubernetes CLI" wget -O kubectl "http://storage.googleapis.com/kubernetes-release/release/${K8S_VERSION}/bin/linux/amd64/kubectl" -chmod 755 kubectl -./kubectl get nodes +sudo chmod +x kubectl +sudo mv kubectl /usr/local/bin/ -set +x -echo "Waiting for master components to start..." -for i in {1..300} -do - running_count=$(./kubectl -s=http://127.0.0.1:8080 get pods --no-headers 2>/dev/null | grep "Running" | wc -l) - # We expect to have 3 running pods - etcd, master and kube-proxy. - if [ "$running_count" -ge 3 ]; then - break - fi - echo -n "." - sleep 1 -done -set -x +echo "Download localkube from minikube project" +wget -O localkube "https://storage.googleapis.com/minikube/k8sReleases/v1.6.0-alpha.0/localkube-linux-amd64" +sudo chmod +x localkube +sudo mv localkube /usr/local/bin/ -echo "SUCCESS" -echo "Cluster created!" -echo "" +echo "Starting localkube" +sudo nohup localkube --logtostderr=true --enable-dns=false > localkube.log 2>&1 & + +echo "Waiting for localkube to start..." +if ! timeout 120 sh -c "while ! curl -ks https://127.0.0.1:8443/ >/dev/null; do sleep 1; done"; then + sudo cat localkube.log + die $LINENO "localkube did not start" +fi echo "Dump Kubernetes Objects..." -./kubectl -s=http://127.0.0.1:8080 get componentstatuses -./kubectl -s=http://127.0.0.1:8080 get configmaps -./kubectl -s=http://127.0.0.1:8080 get daemonsets -./kubectl -s=http://127.0.0.1:8080 get deployments -./kubectl -s=http://127.0.0.1:8080 get events -./kubectl -s=http://127.0.0.1:8080 get endpoints -./kubectl -s=http://127.0.0.1:8080 get horizontalpodautoscalers -./kubectl -s=http://127.0.0.1:8080 get ingress -./kubectl -s=http://127.0.0.1:8080 get jobs -./kubectl -s=http://127.0.0.1:8080 get limitranges -./kubectl -s=http://127.0.0.1:8080 get nodes -./kubectl -s=http://127.0.0.1:8080 get namespaces -./kubectl -s=http://127.0.0.1:8080 get pods -./kubectl -s=http://127.0.0.1:8080 get persistentvolumes -./kubectl -s=http://127.0.0.1:8080 get persistentvolumeclaims -./kubectl -s=http://127.0.0.1:8080 get quota -./kubectl -s=http://127.0.0.1:8080 get resourcequotas -./kubectl -s=http://127.0.0.1:8080 get replicasets -./kubectl -s=http://127.0.0.1:8080 get replicationcontrollers -./kubectl -s=http://127.0.0.1:8080 get secrets -./kubectl -s=http://127.0.0.1:8080 get serviceaccounts -./kubectl -s=http://127.0.0.1:8080 get services +kubectl get componentstatuses +kubectl get configmaps +kubectl get daemonsets +kubectl get deployments +kubectl get events +kubectl get endpoints +kubectl get horizontalpodautoscalers +kubectl get ingress +kubectl get jobs +kubectl get limitranges +kubectl get nodes +kubectl get namespaces +kubectl get pods +kubectl get persistentvolumes +kubectl get persistentvolumeclaims +kubectl get quota +kubectl get resourcequotas +kubectl get replicasets +kubectl get replicationcontrollers +kubectl get secrets +kubectl get serviceaccounts +kubectl get services echo "Running tests..." From 4e593a7530a8751c817cceec715bfe1d03997793 Mon Sep 17 00:00:00 2001 From: mbohlool Date: Wed, 15 Feb 2017 12:37:43 -0800 Subject: [PATCH 11/12] Improvements on ws_client. Now the client can returns an object to interact with websocket server and reach each channel separately --- examples/exec.py | 94 ++++++++++++++ kubernetes/client/api_client.py | 12 +- kubernetes/client/ws_client.py | 195 +++++++++++++++++++++++------ kubernetes/e2e_test/base.py | 1 + kubernetes/e2e_test/test_client.py | 41 ++++-- 5 files changed, 291 insertions(+), 52 deletions(-) create mode 100644 examples/exec.py diff --git a/examples/exec.py b/examples/exec.py new file mode 100644 index 000000000..f9b21b634 --- /dev/null +++ b/examples/exec.py @@ -0,0 +1,94 @@ +import time + +from kubernetes import config +from kubernetes.client import configuration +from kubernetes.client.apis import core_v1_api +from kubernetes.client.rest import ApiException + +config.load_kube_config() +configuration.assert_hostname = False +api = core_v1_api.CoreV1Api() +name = 'busybox-test' + +resp = None +try: + resp = api.read_namespaced_pod(name=name, + namespace='default') +except ApiException as e: + if e.status != 404: + print("Unknown error: %s" % e) + exit(1) + +if not resp: + print("Pod %s does not exits. Creating it..." % name) + pod_manifest = { + 'apiVersion': 'v1', + 'kind': 'Pod', + 'metadata': { + 'name': name + }, + 'spec': { + 'containers': [{ + 'image': 'busybox', + 'name': 'sleep', + "args": [ + "/bin/sh", + "-c", + "while true;do date;sleep 5; done" + ] + }] + } + } + resp = api.create_namespaced_pod(body=pod_manifest, + namespace='default') + while True: + resp = api.read_namespaced_pod(name=name, + namespace='default') + if resp.status.phase != 'Pending': + break + time.sleep(1) + print("Done.") + + +# calling exec and wait for response. +exec_command = [ + '/bin/sh', + '-c', + 'echo This message goes to stderr >&2; echo This message goes to stdout'] +resp = api.connect_get_namespaced_pod_exec(name, 'default', + command=exec_command, + stderr=True, stdin=False, + stdout=True, tty=False) +print("Response: " + resp) + +# Calling exec interactively. +exec_command = ['/bin/sh'] +resp = api.connect_get_namespaced_pod_exec(name, 'default', + command=exec_command, + stderr=True, stdin=True, + stdout=True, tty=False, + + _preload_content=False) +commands = [ + "echo test1", + "echo \"This message goes to stderr\" >&2", +] +while resp.is_open(): + resp.update(timeout=1) + if resp.peek_stdout(): + print("STDOUT: %s" % resp.read_stdout()) + if resp.peek_stderr(): + print("STDERR: %s" % resp.read_stderr()) + if commands: + c = commands.pop(0) + print("Running command... %s\n" % c) + resp.write_stdin(c + "\n") + else: + break + +resp.write_stdin("date\n") +sdate = resp.readline_stdout(timeout=3) +print("Server date command returns: %s" % sdate) +resp.write_stdin("whoami\n") +user = resp.readline_stdout(timeout=3) +print("Server user is: %s" % user) diff --git a/kubernetes/client/api_client.py b/kubernetes/client/api_client.py index 6dbe7137d..7dca16a16 100644 --- a/kubernetes/client/api_client.py +++ b/kubernetes/client/api_client.py @@ -347,12 +347,12 @@ class ApiClient(object): # FIXME(dims) : We need a better way to figure out which # calls end up using web sockets if url.endswith('/exec') and (method == "GET" or method == "POST"): - return ws_client.GET(self.config, - url, - query_params=query_params, - _request_timeout=_request_timeout, - headers=headers) - + return ws_client.websocket_call(self.config, + url, + query_params=query_params, + _request_timeout=_request_timeout, + _preload_content=_preload_content, + headers=headers) if method == "GET": return self.rest_client.GET(url, query_params=query_params, diff --git a/kubernetes/client/ws_client.py b/kubernetes/client/ws_client.py index b143400be..ceaaa72fe 100644 --- a/kubernetes/client/ws_client.py +++ b/kubernetes/client/ws_client.py @@ -12,33 +12,40 @@ from .rest import ApiException +import select import certifi +import time import collections -import websocket +from websocket import WebSocket, ABNF, enableTrace import six import ssl from six.moves.urllib.parse import urlencode from six.moves.urllib.parse import quote_plus +STDIN_CHANNEL = 0 +STDOUT_CHANNEL = 1 +STDERR_CHANNEL = 2 + class WSClient: def __init__(self, configuration, url, headers): - self.messages = [] - self.errors = [] - websocket.enableTrace(False) - header = None + """A websocket client with support for channels. + + Exec command uses different channels for different streams. for + example, 0 is stdin, 1 is stdout and 2 is stderr. Some other API calls + like port forwarding can forward different pods' streams to different + channels. + """ + enableTrace(False) + header = [] + self._connected = False + self._channels = {} + self._all = "" # We just need to pass the Authorization, ignore all the other # http headers we get from the generated code - if 'Authorization' in headers: - header = "Authorization: %s" % headers['Authorization'] - - self.ws = websocket.WebSocketApp(url, - on_message=self.on_message, - on_error=self.on_error, - on_close=self.on_close, - header=[header] if header else None) - self.ws.on_open = self.on_open + if headers and 'authorization' in headers: + header.append("authorization: %s" % headers['authorization']) if url.startswith('wss://') and configuration.verify_ssl: ssl_opts = { @@ -52,30 +59,145 @@ class WSClient: else: ssl_opts = {'cert_reqs': ssl.CERT_NONE} - self.ws.run_forever(sslopt=ssl_opts) + self.sock = WebSocket(sslopt=ssl_opts, skip_utf8_validation=False) + self.sock.connect(url, header=header) + self._connected = True - def on_message(self, ws, message): - if message[0] == '\x01': - message = message[1:] - if message: - if six.PY3 and isinstance(message, six.binary_type): - message = message.decode('utf-8') - self.messages.append(message) + def peek_channel(self, channel, timeout=0): + """Peek a channel and return part of the input, + empty string otherwise.""" + self.update(timeout=timeout) + if channel in self._channels: + return self._channels[channel] + return "" - def on_error(self, ws, error): - self.errors.append(error) + def read_channel(self, channel, timeout=0): + """Read data from a channel.""" + if channel not in self._channels: + ret = self.peek_channel(channel, timeout) + else: + ret = self._channels[channel] + if channel in self._channels: + del self._channels[channel] + return ret - def on_close(self, ws): - pass + def readline_channel(self, channel, timeout=None): + """Read a line from a channel.""" + if timeout is None: + timeout = float("inf") + start = time.time() + while self.is_open() and time.time() - start < timeout: + if channel in self._channels: + data = self._channels[channel] + if "\n" in data: + index = data.find("\n") + ret = data[:index] + data = data[index+1:] + if data: + self._channels[channel] = data + else: + del self._channels[channel] + return ret + self.update(timeout=(timeout - time.time() + start)) - def on_open(self, ws): - pass + def write_channel(self, channel, data): + """Write data to a channel.""" + self.sock.send(chr(channel) + data) + + def peek_stdout(self, timeout=0): + """Same as peek_channel with channel=1.""" + return self.peek_channel(STDOUT_CHANNEL, timeout=timeout) + + def read_stdout(self, timeout=None): + """Same as read_channel with channel=1.""" + return self.read_channel(STDOUT_CHANNEL, timeout=timeout) + + def readline_stdout(self, timeout=None): + """Same as readline_channel with channel=1.""" + return self.readline_channel(STDOUT_CHANNEL, timeout=timeout) + + def peek_stderr(self, timeout=0): + """Same as peek_channel with channel=2.""" + return self.peek_channel(STDERR_CHANNEL, timeout=timeout) + + def read_stderr(self, timeout=None): + """Same as read_channel with channel=2.""" + return self.read_channel(STDERR_CHANNEL, timeout=timeout) + + def readline_stderr(self, timeout=None): + """Same as readline_channel with channel=2.""" + return self.readline_channel(STDERR_CHANNEL, timeout=timeout) + + def read_all(self): + """Read all of the inputs with the same order they recieved. The channel + information would be part of the string. This is useful for + non-interactive call where a set of command passed to the API call and + their result is needed after the call is concluded. + + TODO: Maybe we can process this and return a more meaningful map with + channels mapped for each input. + """ + out = self._all + self._all = "" + self._channels = {} + return out + + def is_open(self): + """True if the connection is still alive.""" + return self._connected + + def write_stdin(self, data): + """The same as write_channel with channel=0.""" + self.write_channel(STDIN_CHANNEL, data) + + def update(self, timeout=0): + """Update channel buffers with at most one complete frame of input.""" + if not self.is_open(): + return + if not self.sock.connected: + self._connected = False + return + r, _, _ = select.select( + (self.sock.sock, ), (), (), timeout) + if r: + op_code, frame = self.sock.recv_data_frame(True) + if op_code == ABNF.OPCODE_CLOSE: + self._connected = False + return + elif op_code == ABNF.OPCODE_BINARY or op_code == ABNF.OPCODE_TEXT: + data = frame.data + if six.PY3: + data = data.decode("utf-8") + self._all += data + if len(data) > 1: + channel = ord(data[0]) + data = data[1:] + if data: + if channel not in self._channels: + self._channels[channel] = data + else: + self._channels[channel] += data + + def run_forever(self, timeout=None): + """Wait till connection is closed or timeout reached. Buffer any input + received during this time.""" + if timeout: + start = time.time() + while self.is_open() and time.time() - start < timeout: + self.update(timeout=(timeout - time.time() + start)) + else: + while self.is_open(): + self.update(timeout=None) WSResponse = collections.namedtuple('WSResponse', ['data']) -def GET(configuration, url, query_params, _request_timeout, headers): +def websocket_call(configuration, url, query_params, _request_timeout, + _preload_content, headers): + """An internal function to be called in api-client when a websocket + connection is required.""" + # switch protocols from http to websocket url = url.replace('http://', 'ws://') url = url.replace('https://', 'wss://') @@ -105,10 +227,11 @@ def GET(configuration, url, query_params, _request_timeout, headers): else: url += '&command=' + quote_plus(commands) - client = WSClient(configuration, url, headers) - if client.errors: - raise ApiException( - status=0, - reason='\n'.join([str(error) for error in client.errors]) - ) - return WSResponse('%s' % ''.join(client.messages)) + try: + client = WSClient(configuration, url, headers) + if not _preload_content: + return client + client.run_forever(timeout=_request_timeout) + return WSResponse('%s' % ''.join(client.read_all())) + except (Exception, KeyboardInterrupt, SystemExit) as e: + raise ApiException(status=0, reason=str(e)) diff --git a/kubernetes/e2e_test/base.py b/kubernetes/e2e_test/base.py index 5f04ab7e8..ee19b14ac 100644 --- a/kubernetes/e2e_test/base.py +++ b/kubernetes/e2e_test/base.py @@ -42,4 +42,5 @@ def get_e2e_configuration(): if config.host is None: raise unittest.SkipTest('Unable to find a running Kubernetes instance') print('Running test against : %s' % config.host) + config.assert_hostname = False return config diff --git a/kubernetes/e2e_test/test_client.py b/kubernetes/e2e_test/test_client.py index 8bc9b3d30..6f19fbdb9 100644 --- a/kubernetes/e2e_test/test_client.py +++ b/kubernetes/e2e_test/test_client.py @@ -18,10 +18,14 @@ import uuid from kubernetes.client import api_client from kubernetes.client.apis import core_v1_api -from kubernetes.client.configuration import configuration from kubernetes.e2e_test import base +def short_uuid(): + id = str(uuid.uuid4()) + return id[-12:] + + class TestClient(unittest.TestCase): @classmethod @@ -32,7 +36,7 @@ class TestClient(unittest.TestCase): client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) - name = 'busybox-test-' + str(uuid.uuid4()) + name = 'busybox-test-' + short_uuid() pod_manifest = { 'apiVersion': 'v1', 'kind': 'Pod', @@ -68,7 +72,7 @@ class TestClient(unittest.TestCase): exec_command = ['/bin/sh', '-c', - 'for i in $(seq 1 3); do date; sleep 1; done'] + 'for i in $(seq 1 3); do date; done'] resp = api.connect_get_namespaced_pod_exec(name, 'default', command=exec_command, stderr=False, stdin=False, @@ -78,12 +82,29 @@ class TestClient(unittest.TestCase): exec_command = 'uptime' resp = api.connect_post_namespaced_pod_exec(name, 'default', - command=exec_command, - stderr=False, stdin=False, - stdout=True, tty=False) + command=exec_command, + stderr=False, stdin=False, + stdout=True, tty=False) print('EXEC response : %s' % resp) self.assertEqual(1, len(resp.splitlines())) + resp = api.connect_post_namespaced_pod_exec(name, 'default', + command='/bin/sh', + stderr=True, stdin=True, + stdout=True, tty=False, + _preload_content=False) + resp.write_stdin("echo test string 1\n") + line = resp.readline_stdout(timeout=5) + self.assertFalse(resp.peek_stderr()) + self.assertEqual("test string 1", line) + resp.write_stdin("echo test string 2 >&2\n") + line = resp.readline_stderr(timeout=5) + self.assertFalse(resp.peek_stdout()) + self.assertEqual("test string 2", line) + resp.write_stdin("exit\n") + resp.update(timeout=5) + self.assertFalse(resp.is_open()) + number_of_pods = len(api.list_pod_for_all_namespaces().items) self.assertTrue(number_of_pods > 0) @@ -94,7 +115,7 @@ class TestClient(unittest.TestCase): client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) - name = 'frontend-' + str(uuid.uuid4()) + name = 'frontend-' + short_uuid() service_manifest = {'apiVersion': 'v1', 'kind': 'Service', 'metadata': {'labels': {'name': name}, @@ -133,7 +154,7 @@ class TestClient(unittest.TestCase): client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) - name = 'frontend-' + str(uuid.uuid4()) + name = 'frontend-' + short_uuid() rc_manifest = { 'apiVersion': 'v1', 'kind': 'ReplicationController', @@ -166,7 +187,7 @@ class TestClient(unittest.TestCase): client = api_client.ApiClient(config=self.config) api = core_v1_api.CoreV1Api(client) - name = 'test-configmap-' + str(uuid.uuid4()) + name = 'test-configmap-' + short_uuid() test_configmap = { "kind": "ConfigMap", "apiVersion": "v1", @@ -195,7 +216,7 @@ class TestClient(unittest.TestCase): resp = api.delete_namespaced_config_map( name=name, body={}, namespace='default') - resp = api.list_namespaced_config_map('kube-system', pretty=True) + resp = api.list_namespaced_config_map('default', pretty=True) self.assertEqual([], resp.items) def test_node_apis(self): From 88563a6619f325ad62fc9ba549fa0d9fbac48298 Mon Sep 17 00:00:00 2001 From: mbohlool Date: Thu, 16 Feb 2017 20:43:38 -0800 Subject: [PATCH 12/12] Update CHANGELOG --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cdfc1a5..3ded7b71c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# v1.0.0b2 +- Support exec calls in both interactive and non-interactive mode #58 + # v1.0.0b1 - Support insecure-skip-tls-verify config flag #99