From 9f84fd6ca13e5fb8889c4881bc596d584d16ec08 Mon Sep 17 00:00:00 2001 From: Dan Callaghan Date: May 22 2017 05:54:27 +0000 Subject: [PATCH 1/6] Dockerfile: include python-psycopg2 ... under the assumption that the container will be hooked up to a Postgres database. --- diff --git a/Dockerfile b/Dockerfile index 8151565..b0516d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,11 @@ RUN yum -y install epel-release && yum -y clean all ARG waiverdb_rpm COPY $waiverdb_rpm /tmp # XXX take out epel-testing eventually -RUN yum -y install --setopt=tsflags=nodocs --enablerepo=epel-testing python-gunicorn /tmp/$(basename $waiverdb_rpm) && yum -y clean all +RUN yum -y install --setopt=tsflags=nodocs --enablerepo=epel-testing \ + python-gunicorn \ + python-psycopg2 \ + /tmp/$(basename $waiverdb_rpm) \ + && yum -y clean all USER 1001 EXPOSE 8080 ENTRYPOINT gunicorn --bind 0.0.0.0:8080 --access-logfile=- waiverdb.wsgi:app From fa5732d7ba32474863e02fa99fdd3b80b6d9177f Mon Sep 17 00:00:00 2001 From: Dan Callaghan Date: May 22 2017 05:54:27 +0000 Subject: [PATCH 2/6] allow setting DATABASE_PASSWORD in the environment Renamed the setting to DATABASE_URI, with the password filled into the URI from the DATABASE_PASSWORD environment variable if that is defined. The SQLALCHEMY_DATABASE_URI setting is now just used internally within the app to pass the complete URI down to Flask-SQLAlchemy. This change is for OpenShift, which does not have any simple way to inject secrets into configuration files. It is simpler to make the configuration be non-secret, and pass secrets in as environment variables. --- diff --git a/conf/settings.py.example b/conf/settings.py.example index 92a4e3d..87c49a6 100644 --- a/conf/settings.py.example +++ b/conf/settings.py.example @@ -1,8 +1,8 @@ # Copy this file to `conf/settings.py` to put it into effect. It overrides the values defined # in `waiverdb/config.py`. SECRET_KEY = 'replace-me-with-something-random' -#SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://dbuser:dbpassword@dbhost:dbport/dbname' -SQLALCHEMY_DATABASE_URI = 'sqlite:////var/tmp/waiverdb_db.sqlite' +#DATABASE_URI = 'postgresql+psycopg2://dbuser:dbpassword@dbhost:dbport/dbname' +DATABASE_URI = 'sqlite:////var/tmp/waiverdb_db.sqlite' JOURNAL_LOGGING = False #SHOW_DB_URI = False HOST= '0.0.0.0' diff --git a/waiverdb/app.py b/waiverdb/app.py index 8cd26b7..8190543 100644 --- a/waiverdb/app.py +++ b/waiverdb/app.py @@ -10,6 +10,7 @@ # GNU General Public License for more details. import os +import urlparse from flask import Flask from sqlalchemy import event @@ -37,6 +38,25 @@ def load_config(app): app.config.from_pyfile(config_file) +def populate_db_config(app): + # Take the application-level DATABASE_URI setting, plus (optionally) + # a DATABASE_PASSWORD from the environment, and munge them together into + # the SQLALCHEMY_DATABASE_URI setting which is obeyed by Flask-SQLAlchemy. + dburi = app.config['DATABASE_URI'] + if os.environ.get('DATABASE_PASSWORD'): + parsed = urlparse.urlparse(dburi) + netloc = '{}:{}@{}'.format(parsed.username, + os.environ['DATABASE_PASSWORD'], + parsed.hostname) + if parsed.port: + netloc += ':{}'.format(parsed.port) + dburi = urlparse.urlunsplit( + (parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) + if app.config['SHOW_DB_URI']: + app.logger.debug('using DBURI: %s', dburi) + app.config['SQLALCHEMY_DATABASE_URI'] = dburi + + # applicaiton factory http://flask.pocoo.org/docs/0.12/patterns/appfactories/ def create_app(config_obj=None): app = Flask(__name__) @@ -46,8 +66,7 @@ def create_app(config_obj=None): load_config(app) if app.config['PRODUCTION'] and app.secret_key == 'replace-me-with-something-random': raise Warning("You need to change the app.secret_key value for production") - if app.config['SHOW_DB_URI']: - app.logger.debug('using DBURI: %s' % app.config['SQLALCHEMY_DATABASE_URI']) + populate_db_config(app) if app.config['AUTH_METHOD'] == 'OIDC': app.oidc = OpenIDConnect(app) # initialize db diff --git a/waiverdb/config.py b/waiverdb/config.py index 421d6cf..fd65797 100644 --- a/waiverdb/config.py +++ b/waiverdb/config.py @@ -21,7 +21,7 @@ class Config(object): fedmsg when new waivers are created. """ DEBUG = True - SQLALCHEMY_DATABASE_URI = 'sqlite://' + DATABASE_URI = 'sqlite://' JOURNAL_LOGGING = False HOST = '0.0.0.0' PORT = 5004 @@ -45,7 +45,7 @@ class ProductionConfig(Config): class DevelopmentConfig(Config): SQLALCHEMY_TRACK_MODIFICATIONS = True TRAP_BAD_REQUEST_ERRORS = True - SQLALCHEMY_DATABASE_URI = 'sqlite:////var/tmp/waiverdb_db.sqlite' + DATABASE_URI = 'sqlite:////var/tmp/waiverdb_db.sqlite' SHOW_DB_URI = True # The location of the client_secrets.json file used for API authentication OIDC_CLIENT_SECRETS = os.path.join( From 383be2e2d060b2c5eb68d8b4de0644ce44dbfa60 Mon Sep 17 00:00:00 2001 From: Dan Callaghan Date: May 22 2017 05:55:01 +0000 Subject: [PATCH 3/6] allow setting SECRET_KEY in the environment Similar to the previous commit. This is for OpenShift, which lacks facilities for injecting secrets into non-secret config. --- diff --git a/waiverdb/app.py b/waiverdb/app.py index 8190543..6284ded 100644 --- a/waiverdb/app.py +++ b/waiverdb/app.py @@ -36,6 +36,8 @@ def load_config(app): app.config.from_object(default_config_obj) config_file = os.environ.get('WAIVERDB_CONFIG', default_config_file) app.config.from_pyfile(config_file) + if os.environ.get('SECRET_KEY'): + app.config['SECRET_KEY'] = os.environ['SECRET_KEY'] def populate_db_config(app): From d47ca14850ef52d4f4decc8e75ade8fd93c546ab Mon Sep 17 00:00:00 2001 From: Dan Callaghan Date: May 22 2017 05:55:02 +0000 Subject: [PATCH 4/6] OpenShift template for producing a test environment --- diff --git a/openshift/waiverdb-test-template.yaml b/openshift/waiverdb-test-template.yaml new file mode 100644 index 0000000..6a7f913 --- /dev/null +++ b/openshift/waiverdb-test-template.yaml @@ -0,0 +1,215 @@ + +# Template to produce a new test environment in OpenShift. Uses OpenID Connect +# against iddev.fedorainfracloud.org for authentication, and ephemeral storage +# for Postgres data. +# +# To create an environment from the template, process and apply it: +# oc process -f openshift/waiverdb-test-template.yaml -p TEST_ID=123 -p WAIVERDB_APP_VERSION=0.1.2.dev24-git.94c0119 | oc apply -f - +# To clean up the environment, use a selector on the environment label: +# oc delete dc,deploy,pod,configmap,secret,svc,route -l environment=test-123 + +--- +apiVersion: v1 +kind: Template +metadata: + name: waiverdb-test-template +parameters: +- name: TEST_ID + displayName: Test id + description: Short unique identifier for this test run (e.g. Jenkins job number) + required: true +- name: WAIVERDB_APP_VERSION + displayName: WaiverDB application version + description: Python version of the WaiverDB application being tested + required: true +- name: FLASK_SECRET_KEY + displayName: Flask secret key + generate: expression + from: "[\\w]{32}" +- name: DATABASE_PASSWORD + displayName: Database password + generate: expression + from: "[\\w]{32}" +objects: +- apiVersion: v1 + kind: Secret + metadata: + name: "waiverdb-test-${TEST_ID}-secret" + labels: + environment: "test-${TEST_ID}" + stringData: + flask-secret-key: "${FLASK_SECRET_KEY}" + database-password: "${DATABASE_PASSWORD}" + # This is the same non-secret config we have committed + # as conf/client_secrets.json for using in dev environments. + client_secrets.json: |- + {"web": { + "redirect_uris": ["http://localhost:8080/"], + "token_uri": "https://iddev.fedorainfracloud.org/openidc/Token", + "auth_uri": "https://iddev.fedorainfracloud.org/openidc/Authorization", + "client_id": "D-e69a1ac7-30fa-4d18-9001-7468c4f34c3c", + "client_secret": "qgz8Bzjg6nO7JWCXoB0o8L49KfI5atLF", + "userinfo_uri": "https://iddev.fedorainfracloud.org/openidc/UserInfo", + "token_introspection_uri": "https://iddev.fedorainfracloud.org/openidc/TokenInfo"}} +- apiVersion: v1 + kind: ConfigMap + metadata: + name: "waiverdb-test-${TEST_ID}-configmap" + labels: + environment: "test-${TEST_ID}" + data: + settings.py: |- + DATABASE_URI = 'postgresql+psycopg2://waiverdb@waiverdb-test-${TEST_ID}-database:5432/waiverdb' + PORT = 8080 + AUTH_METHOD = 'OIDC' + OIDC_CLIENT_SECRETS = '/etc/secret/client_secrets.json' +- apiVersion: v1 + kind: Service + metadata: + name: "waiverdb-test-${TEST_ID}-database" + labels: + environment: "test-${TEST_ID}" + spec: + selector: + environment: "test-${TEST_ID}" + service: database + ports: + - name: postgresql + port: 5432 + targetPort: 5432 +- apiVersion: v1 + kind: DeploymentConfig + metadata: + name: "waiverdb-test-${TEST_ID}-database" + labels: + environment: "test-${TEST_ID}" + service: database + spec: + replicas: 1 + strategy: + type: Recreate + selector: + environment: "test-${TEST_ID}" + service: database + template: + metadata: + labels: + environment: "test-${TEST_ID}" + service: database + spec: + containers: + - name: postgresql + image: registry.access.redhat.com/rhscl/postgresql-95-rhel7:latest + imagePullPolicy: Always + ports: + - containerPort: 5432 + readinessProbe: + timeoutSeconds: 1 + initialDelaySeconds: 5 + exec: + command: [ /bin/sh, -i, -c, "psql -h 127.0.0.1 -U $POSTGRESQL_USER -q -d $POSTGRESQL_DATABASE -c 'SELECT 1'" ] + livenessProbe: + timeoutSeconds: 1 + initialDelaySeconds: 30 + tcpSocket: + port: 5432 + env: + - name: POSTGRESQL_USER + value: waiverdb + - name: POSTGRESQL_PASSWORD + valueFrom: + secretKeyRef: + name: "waiverdb-test-${TEST_ID}-secret" + key: database-password + - name: POSTGRESQL_DATABASE + value: waiverdb + triggers: + - type: ConfigChange +- apiVersion: v1 + kind: Service + metadata: + name: "waiverdb-test-${TEST_ID}-web" + labels: + environment: "test-${TEST_ID}" + annotations: + service.alpha.openshift.io/dependencies: |- + [{"name": "waiverdb-test-${TEST_ID}-database", "kind": "Service"}] + spec: + selector: + environment: "test-${TEST_ID}" + service: web + ports: + - name: web + port: 8080 + targetPort: 8080 +- apiVersion: v1 + kind: Route + metadata: + name: "waiverdb-test-${TEST_ID}-web" + labels: + environment: "test-${TEST_ID}" + spec: + port: + targetPort: web + to: + kind: Service + name: "waiverdb-test-${TEST_ID}-web" + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect +- apiVersion: v1 + kind: DeploymentConfig + metadata: + name: "waiverdb-test-${TEST_ID}-web" + labels: + environment: "test-${TEST_ID}" + service: web + spec: + replicas: 2 + selector: + environment: "test-${TEST_ID}" + service: web + template: + metadata: + labels: + environment: "test-${TEST_ID}" + service: web + spec: + containers: + - name: web + image: "docker-registry.engineering.redhat.com/factory2/waiverdb:${WAIVERDB_APP_VERSION}" + ports: + - containerPort: 8080 + volumeMounts: + - name: config-volume + mountPath: /etc/waiverdb + readOnly: true + - name: secret-volume + mountPath: /etc/secret + readOnly: true + env: + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: "waiverdb-test-${TEST_ID}-secret" + key: database-password + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: "waiverdb-test-${TEST_ID}-secret" + key: flask-secret-key + # Limit to 384MB memory. This is probably *not* enough but it is + # necessary in the current environment to allow for 2 replicas and + # rolling updates, without hitting the (very aggressive) memory quota. + resources: + limits: + memory: 384Mi + volumes: + - name: config-volume + configMap: + name: "waiverdb-test-${TEST_ID}-configmap" + - name: secret-volume + secret: + secretName: "waiverdb-test-${TEST_ID}-secret" + triggers: + - type: ConfigChange From 6a32484a88401e42298a27ad5e41ef2084d3881f Mon Sep 17 00:00:00 2001 From: Dan Callaghan Date: May 22 2017 05:55:02 +0000 Subject: [PATCH 5/6] add health checks for the web application --- diff --git a/openshift/waiverdb-test-template.yaml b/openshift/waiverdb-test-template.yaml index 6a7f913..541d1d3 100644 --- a/openshift/waiverdb-test-template.yaml +++ b/openshift/waiverdb-test-template.yaml @@ -198,6 +198,18 @@ objects: secretKeyRef: name: "waiverdb-test-${TEST_ID}-secret" key: flask-secret-key + readinessProbe: + timeoutSeconds: 1 + initialDelaySeconds: 5 + httpGet: + path: /healthcheck + port: 8080 + livenessProbe: + timeoutSeconds: 1 + initialDelaySeconds: 30 + httpGet: + path: /healthcheck + port: 8080 # Limit to 384MB memory. This is probably *not* enough but it is # necessary in the current environment to allow for 2 replicas and # rolling updates, without hitting the (very aggressive) memory quota. diff --git a/waiverdb/app.py b/waiverdb/app.py index 6284ded..89a17a1 100644 --- a/waiverdb/app.py +++ b/waiverdb/app.py @@ -77,6 +77,7 @@ def create_app(config_obj=None): init_logging(app) # register blueprints app.register_blueprint(api_v1, url_prefix="/api/v1.0") + app.add_url_rule('/healthcheck', view_func=healthcheck) register_event_handlers(app) return app @@ -87,6 +88,19 @@ def init_db(app): return db +def healthcheck(): + """ + Request handler for performing an application-level health check. This is + not part of the published API, it is intended for use by OpenShift or other + monitoring tools. + + Returns a 200 response if the application is alive and able to serve requests. + """ + result = db.session.execute('SELECT 1').scalar() + assert result == 1 + return ('Health check OK', 200, [('Content-Type', 'text/plain')]) + + def register_event_handlers(app): """ Register SQLAlchemy event handlers with the application's session factory. From 0e08e6772877944a9d696b70c92eef0e0e4e4064 Mon Sep 17 00:00:00 2001 From: Dan Callaghan Date: May 22 2017 05:55:02 +0000 Subject: [PATCH 6/6] Jenkinsfile: deploy and test the app inside OpenShift --- diff --git a/Jenkinsfile b/Jenkinsfile index 1888f97..691b13a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -116,6 +116,9 @@ node('rcm-tools-jslave-rhel-7-docker') { def image = docker.build "factory2/waiverdb:${appversion}", "--build-arg waiverdb_rpm=$el7_rpm ." image.push() } + /* Save container version for later steps (this is ugly but I can't find anything better...) */ + writeFile file: 'appversion', text: appversion + archiveArtifacts artifacts: 'appversion' } } catch (e) { currentBuild.result = "FAILED" @@ -126,3 +129,47 @@ node('rcm-tools-jslave-rhel-7-docker') { throw e } } +node('fedora') { + sh 'sudo dnf -y install /usr/bin/py.test' + checkout scm + stage('Perform functional tests') { + unarchive mapping: ['appversion': 'appversion'] + def appversion = readFile('appversion').trim() + openshift.withCluster('open.paas.redhat.com') { + openshift.doAs('openpaas-waiverdb-test-jenkins-credentials') { + openshift.withProject('waiverdb-test') { + def template = readYaml file: 'openshift/waiverdb-test-template.yaml' + def models = openshift.process(template, + '-p', "TEST_ID=${env.BUILD_TAG}", + '-p', "WAIVERDB_APP_VERSION=${appversion}") + def environment_label = "test-${env.BUILD_TAG}" + try { + def objects = openshift.create(models) + echo "Waiting for pods with label environment=${environment_label} to become Ready" + def pods = openshift.selector('pods', ['environment': environment_label]) + timeout(15) { + pods.untilEach(3) { + def conds = it.object().status.conditions + for (int i = 0; i < conds.size(); i++) { + if (conds[i].type == 'Ready' && conds[i].status == 'True') { + return true + } + } + return false + } + } + def route_hostname = objects.narrow('route').object().spec.host + echo "Running tests against https://${route_hostname}/" + withEnv(["WAIVERDB_TEST_URL=https://${route_hostname}/"]) { + sh 'py.test functional-tests/' + } + } finally { + /* Tear down everything we just created */ + openshift.selector('dc,deploy,pod,configmap,secret,svc,route', + ['environment': environment_label]).delete() + } + } + } + } + } +} diff --git a/functional-tests/conftest.py b/functional-tests/conftest.py new file mode 100644 index 0000000..7d16a68 --- /dev/null +++ b/functional-tests/conftest.py @@ -0,0 +1,32 @@ + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# + +import os +import pytest +import requests + + +@pytest.fixture(scope='session') +def waiverdb_url(): + if 'WAIVERDB_TEST_URL' not in os.environ: + raise AssertionError('WAIVERDB_TEST_URL=http://example.com/ ' + 'must be set in the environment') + url = os.environ['WAIVERDB_TEST_URL'] + assert url.endswith('/') + return url + + +@pytest.fixture(scope='session') +def requests_session(request): + s = requests.Session() + request.addfinalizer(s.close) + return s diff --git a/functional-tests/test_api_v1.py b/functional-tests/test_api_v1.py new file mode 100644 index 0000000..167f18a --- /dev/null +++ b/functional-tests/test_api_v1.py @@ -0,0 +1,17 @@ + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# + + +def test_get_waivers(waiverdb_url, requests_session): + response = requests_session.get(waiverdb_url + 'api/v1.0/waivers/') + response.raise_for_status() + assert response.json()['data'] == []