diff --git a/components/echo-server/Dockerfile b/components/echo-server/Dockerfile new file mode 100644 index 00000000000..34fdee58ae7 --- /dev/null +++ b/components/echo-server/Dockerfile @@ -0,0 +1,8 @@ +FROM python:2.7-slim + +ADD . /app +WORKDIR /app + +RUN pip install -r requirements.txt + +ENTRYPOINT ["gunicorn", "-b", ":8080", "main:app"] diff --git a/components/echo-server/Makefile b/components/echo-server/Makefile new file mode 100755 index 00000000000..367ea821b50 --- /dev/null +++ b/components/echo-server/Makefile @@ -0,0 +1,40 @@ +# Copyright 2017 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. +# +# Requirements: +# https://github.com/mattrobenolt/jinja2-cli +# pip install jinja2-clie +IMG = gcr.io/kubeflow-images-staging/echo-server + +TAG := $(shell date +v%Y%m%d)-$(shell git describe --always --dirty)-$(shell git diff | shasum -a256 | cut -c -6) +DIR := ${CURDIR} + +all: build + +# To build without the cache set the environment variable +# export DOCKER_BUILD_OPTS=--no-cache +build: + docker build ${DOCKER_BUILD_OPTS} -t $(IMG):$(TAG) . + docker tag ${DOCKER_BUILD_OPTS} $(IMG):$(TAG) $(IMG):latest + @echo Built $(IMG):$(TAG) + +# Build but don't attach the latest tag. This allows manual testing/inspection of the image +# first. +push: build + gcloud docker -- push $(IMG):$(TAG) + @echo Pushed $(IMG) with :$(TAG) tags + +push-latest: push + gcloud container images add-tag --quiet $(IMG):$(TAG) $(IMG):latest --verbosity=info + echo created $(IMG):latest diff --git a/components/echo-server/main.py b/components/echo-server/main.py new file mode 100644 index 00000000000..5b17864a894 --- /dev/null +++ b/components/echo-server/main.py @@ -0,0 +1,98 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# 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. + +"""Google Cloud Endpoints sample application. + +Demonstrates how to create a simple echo API as well as how to deal with +various authentication methods. +""" + +import base64 +import json +import logging + +from flask import Flask, jsonify, request +from flask_cors import cross_origin +from six.moves import http_client + + +app = Flask(__name__) + + +def _base64_decode(encoded_str): + # Add paddings manually if necessary. + num_missed_paddings = 4 - len(encoded_str) % 4 + if num_missed_paddings != 4: + encoded_str += b'=' * num_missed_paddings + return base64.b64decode(encoded_str).decode('utf-8') + + +@app.route('/echo', methods=['POST']) +def echo(): + """Simple echo service.""" + message = request.get_json().get('message', '') + return jsonify({'message': message}) + +@app.route('/') +@app.route('/headers') +def headers(): + return jsonify({'headers': request.headers.to_list()}) + +def auth_info(): + """Retrieves the authenication information from Google Cloud Endpoints.""" + encoded_info = request.headers.get('X-Endpoint-API-UserInfo', None) + + if encoded_info: + info_json = _base64_decode(encoded_info) + user_info = json.loads(info_json) + else: + user_info = {'id': 'anonymous'} + + return jsonify(user_info) + + +@app.route('/auth/info/googlejwt', methods=['GET']) +def auth_info_google_jwt(): + """Auth info with Google signed JWT.""" + return auth_info() + + +@app.route('/auth/info/googleidtoken', methods=['GET']) +def auth_info_google_id_token(): + """Auth info with Google ID token.""" + return auth_info() + + +@app.route('/auth/info/firebase', methods=['GET']) +@cross_origin(send_wildcard=True) +def auth_info_firebase(): + """Auth info with Firebase auth.""" + return auth_info() + + +@app.errorhandler(http_client.INTERNAL_SERVER_ERROR) +def unexpected_error(e): + """Handle exceptions by returning swagger-compliant json.""" + logging.exception('An error occured while processing the request.') + response = jsonify({ + 'code': http_client.INTERNAL_SERVER_ERROR, + 'message': 'Exception: {}'.format(e)}) + response.status_code = http_client.INTERNAL_SERVER_ERROR + return response + + +if __name__ == '__main__': + # This is used when running locally. Gunicorn is used to run the + # application on Google App Engine. See entrypoint in app.yaml. + app.run(host='127.0.0.1', port=8080, debug=True) diff --git a/components/echo-server/main_test.py b/components/echo-server/main_test.py new file mode 100644 index 00000000000..6ca6b5093d3 --- /dev/null +++ b/components/echo-server/main_test.py @@ -0,0 +1,82 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# 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 base64 +import json +import os + +import pytest + +import main + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.chdir(os.path.dirname(main.__file__)) + main.app.testing = True + client = main.app.test_client() + return client + + +def test_echo(client): + r = client.post( + '/echo', + data='{"message": "Hello"}', + headers={ + 'Content-Type': 'application/json' + }) + + assert r.status_code == 200 + data = json.loads(r.data.decode('utf-8')) + assert data['message'] == 'Hello' + + +def test_auth_info(client): + endpoints = [ + '/auth/info/googlejwt', + '/auth/info/googleidtoken', + '/auth/info/firebase'] + + encoded_info = base64.b64encode(json.dumps({ + 'id': '123' + }).encode('utf-8')) + + for endpoint in endpoints: + r = client.get( + endpoint, + headers={ + 'Content-Type': 'application/json' + }) + + assert r.status_code == 200 + data = json.loads(r.data.decode('utf-8')) + assert data['id'] == 'anonymous' + + r = client.get( + endpoint, + headers={ + 'Content-Type': 'application/json', + 'X-Endpoint-API-UserInfo': encoded_info + }) + + assert r.status_code == 200 + data = json.loads(r.data.decode('utf-8')) + assert data['id'] == '123' + + +def test_cors(client): + r = client.options( + '/auth/info/firebase', headers={'Origin': 'example.com'}) + assert r.status_code == 200 + assert r.headers['Access-Control-Allow-Origin'] == '*' diff --git a/components/echo-server/requirements.txt b/components/echo-server/requirements.txt new file mode 100644 index 00000000000..afaec1210b6 --- /dev/null +++ b/components/echo-server/requirements.txt @@ -0,0 +1,8 @@ +Flask==0.12.2 +flask-cors==3.0.3 +gunicorn==19.7.1 +six==1.11.0 +pyyaml==3.12 +requests==2.18.4 +google-auth==1.4.1 +google-auth-oauthlib==0.2.0 diff --git a/kubeflow/core/echo-server.libsonnet b/kubeflow/core/echo-server.libsonnet new file mode 100644 index 00000000000..80e235519c1 --- /dev/null +++ b/kubeflow/core/echo-server.libsonnet @@ -0,0 +1,79 @@ +{ + service(namespace, name):: { + apiVersion: "v1", + kind: "Service", + metadata: { + labels: { + app: name, + }, + name: name, + namespace: namespace, + annotations: { + "getambassador.io/config": + std.join("\n", [ + "---", + "apiVersion: ambassador/v0", + "kind: Mapping", + "name: " + name + "-mapping", + "prefix: /" + name, + "rewrite: /", + "service: " + name + "." + namespace, + ]), + }, //annotations + }, + spec: { + ports: [ + { + port: 80, + targetPort: 8080, + }, + ], + selector: { + app: name, + }, + type: "ClusterIP", + }, + }, + + deploy(namespace, name, image):: { + apiVersion: "extensions/v1beta1", + kind: "Deployment", + metadata: { + name: name, + namespace: namespace, + + }, + spec: { + replicas: 1, + template: { + metadata: { + labels: { + app: name, + }, + }, + spec: { + containers: [ + { + image: image, + name: "app", + ports: [ + { + containerPort: 8080, + }, + ], + + readinessProbe: { + httpGet: { + path: "/headers", + port: 8080, + }, + initialDelaySeconds: 5, + periodSeconds: 30, + }, + }, + ], + }, + }, + }, + }, +} \ No newline at end of file diff --git a/kubeflow/core/prototypes/echo-server.jsonnet b/kubeflow/core/prototypes/echo-server.jsonnet new file mode 100644 index 00000000000..3b00166bba9 --- /dev/null +++ b/kubeflow/core/prototypes/echo-server.jsonnet @@ -0,0 +1,16 @@ +// @apiVersion 0.1 +// @name io.ksonnet.pkg.echo-server +// @description Provides a simple server for testing connections; primarily IAP. +// @shortDescription A simple echo server. +// @param name string Name for the component +// @optionalParam image string gcr.io/kubeflow-images-staging/echo-server:v20180628-e545118c-dirty-8a27d6 The image to use. + +local k = import "k.libsonnet"; + +// TODO(https://github.com/ksonnet/ksonnet/issues/670) If we don't import the service +// from a libsonnet file the annotation doesn't end up being escaped/represented in a way that +// Ambassador can understand. +local echoParts = import "kubeflow/core/echo-server.libsonnet"; +local namespace = env.namespace; + +std.prune(k.core.v1.list.new([echoParts.service(namespace, params.name), echoParts.deploy(namespace, params.name, params.image)]))