From fd66fbd7192919d898d19c1e039040ead42adc00 Mon Sep 17 00:00:00 2001 From: bandesz Date: Thu, 8 Dec 2016 12:12:45 +0000 Subject: [PATCH 1/5] Run API on Paas --- .cfignore | 1 + .gitignore | 4 +- Makefile | 121 +++++++++++++++-- app/.gitignore | 1 + app/__init__.py | 9 +- app/cloudfoundry_config.py | 62 +++++++++ config.py => app/config.py | 46 +++++-- aws_run_celery.py | 5 +- db.py | 5 +- docker/{Dockerfile-build => Dockerfile} | 9 ++ docker/Makefile | 14 +- docker/VERSION | 1 + manifest-api-db-migration.yml | 19 +++ manifest-api-preview.yml | 20 +++ manifest-api-production.yml | 20 +++ manifest-api-sandbox.yml | 19 +++ manifest-api-staging.yml | 20 +++ manifest-delivery-celery-beat.yml | 19 +++ manifest-delivery-worker-database.yml | 19 +++ manifest-delivery-worker-research.yml | 19 +++ manifest-delivery-worker-sender.yml | 19 +++ manifest-delivery-worker.yml | 19 +++ requirements.txt | 3 +- runtime.txt | 1 + server_commands.py | 7 +- tests/app/test_cloudfoundry_config.py | 167 ++++++++++++++++++++++++ tests/app/test_config.py | 80 ++++++++++++ tests/conftest.py | 13 +- wsgi.py | 5 +- 29 files changed, 703 insertions(+), 44 deletions(-) create mode 120000 .cfignore create mode 100644 app/.gitignore create mode 100644 app/cloudfoundry_config.py rename config.py => app/config.py (89%) rename docker/{Dockerfile-build => Dockerfile} (60%) create mode 100644 docker/VERSION create mode 100644 manifest-api-db-migration.yml create mode 100644 manifest-api-preview.yml create mode 100644 manifest-api-production.yml create mode 100644 manifest-api-sandbox.yml create mode 100644 manifest-api-staging.yml create mode 100644 manifest-delivery-celery-beat.yml create mode 100644 manifest-delivery-worker-database.yml create mode 100644 manifest-delivery-worker-research.yml create mode 100644 manifest-delivery-worker-sender.yml create mode 100644 manifest-delivery-worker.yml create mode 100644 runtime.txt create mode 100644 tests/app/test_cloudfoundry_config.py create mode 100644 tests/app/test_config.py diff --git a/.cfignore b/.cfignore new file mode 120000 index 000000000..3e4e48b0b --- /dev/null +++ b/.cfignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2194aa0bc..d9a4d419a 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,4 @@ environment.sh celerybeat-schedule -app/version.py - -wheelhouse/ \ No newline at end of file +wheelhouse/ diff --git a/Makefile b/Makefile index f90dd940a..2fb2068dc 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,8 @@ APP_VERSION_FILE = app/version.py GIT_BRANCH ?= $(shell git symbolic-ref --short HEAD 2> /dev/null || echo "detached") GIT_COMMIT ?= $(shell git rev-parse HEAD) -DOCKER_BUILDER_IMAGE_NAME = govuk/notify-api-builder +DOCKER_IMAGE_TAG := $(shell cat docker/VERSION) +DOCKER_BUILDER_IMAGE_NAME = govuk/notify-api-builder:${DOCKER_IMAGE_TAG} BUILD_TAG ?= notifications-api-manual BUILD_NUMBER ?= 0 @@ -17,6 +18,10 @@ BUILD_URL ?= DOCKER_CONTAINER_PREFIX = ${USER}-${BUILD_TAG} +CF_API ?= api.cloud.service.gov.uk +CF_ORG ?= govuk-notify +CF_SPACE ?= ${DEPLOY_ENV} + .PHONY: help help: @cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -25,8 +30,8 @@ help: venv: venv/bin/activate ## Create virtualenv if it does not exist venv/bin/activate: - test -d venv || virtualenv venv - ./venv/bin/pip install pip-accel + test -d venv || virtualenv venv -p python3 + . venv/bin/activate && pip install pip-accel .PHONY: check-env-vars check-env-vars: ## Check mandatory environment variables @@ -35,6 +40,12 @@ check-env-vars: ## Check mandatory environment variables $(if ${AWS_ACCESS_KEY_ID},,$(error Must specify AWS_ACCESS_KEY_ID)) $(if ${AWS_SECRET_ACCESS_KEY},,$(error Must specify AWS_SECRET_ACCESS_KEY)) +.PHONY: sandbox +sandbox: ## Set environment to sandbox + $(eval export DEPLOY_ENV=sandbox) + $(eval export DNS_NAME="cloudapps.digital") + @true + .PHONY: preview preview: ## Set environment to preview $(eval export DEPLOY_ENV=preview) @@ -56,7 +67,7 @@ production: ## Set environment to production .PHONY: dependencies dependencies: venv ## Install build dependencies mkdir -p ${PIP_ACCEL_CACHE} - PIP_ACCEL_CACHE=${PIP_ACCEL_CACHE} ./venv/bin/pip-accel install -r requirements_for_test.txt + . venv/bin/activate && PIP_ACCEL_CACHE=${PIP_ACCEL_CACHE} pip-accel install -r requirements_for_test.txt .PHONY: generate-version-file generate-version-file: ## Generates the app version file @@ -64,7 +75,10 @@ generate-version-file: ## Generates the app version file .PHONY: build build: dependencies generate-version-file ## Build project - ./venv/bin/pip-accel wheel --wheel-dir=wheelhouse -r requirements.txt + . venv/bin/activate && PIP_ACCEL_CACHE=${PIP_ACCEL_CACHE} pip-accel wheel --wheel-dir=wheelhouse -r requirements.txt + +.PHONY: cf-build +cf-build: dependencies generate-version-file ## Build project for PAAS .PHONY: build-codedeploy-artifact build-codedeploy-artifact: ## Build the deploy artifact for CodeDeploy @@ -105,12 +119,12 @@ deploy-delivery: check-env-vars ## Trigger CodeDeploy for the delivery app .PHONY: coverage coverage: venv ## Create coverage report - ./venv/bin/coveralls + . venv/bin/activate && coveralls .PHONY: prepare-docker-build-image prepare-docker-build-image: ## Prepare the Docker builder image mkdir -p ${PIP_ACCEL_CACHE} - make -C docker build-build-image + make -C docker build .PHONY: build-with-docker build-with-docker: prepare-docker-build-image ## Build inside a Docker container @@ -129,6 +143,23 @@ build-with-docker: prepare-docker-build-image ## Build inside a Docker container ${DOCKER_BUILDER_IMAGE_NAME} \ make build +.PHONY: cf-build-with-docker +cf-build-with-docker: prepare-docker-build-image ## Build inside a Docker container + @docker run -i --rm \ + --name "${DOCKER_CONTAINER_PREFIX}-build" \ + -v "`pwd`:/var/project" \ + -v "${PIP_ACCEL_CACHE}:/var/project/cache/pip-accel" \ + -e GIT_COMMIT=${GIT_COMMIT} \ + -e BUILD_NUMBER=${BUILD_NUMBER} \ + -e BUILD_URL=${BUILD_URL} \ + -e http_proxy="${HTTP_PROXY}" \ + -e HTTP_PROXY="${HTTP_PROXY}" \ + -e https_proxy="${HTTPS_PROXY}" \ + -e HTTPS_PROXY="${HTTPS_PROXY}" \ + -e NO_PROXY="${NO_PROXY}" \ + ${DOCKER_BUILDER_IMAGE_NAME} \ + make cf-build + .PHONY: test-with-docker test-with-docker: prepare-docker-build-image create-docker-test-db ## Run tests inside a Docker container @docker run -i --rm \ @@ -182,5 +213,79 @@ coverage-with-docker: prepare-docker-build-image ## Generates coverage report in clean-docker-containers: ## Clean up any remaining docker containers docker rm -f $(shell docker ps -q -f "name=${DOCKER_CONTAINER_PREFIX}") 2> /dev/null || true +.PHONY: clean clean: - rm -rf node_modules cache target venv .coverage build tests/.cache + rm -rf node_modules cache target venv .coverage build tests/.cache wheelhouse + +.PHONY: cf-login +cf-login: ## Log in to Cloud Foundry + $(if ${CF_USERNAME},,$(error Must specify CF_USERNAME)) + $(if ${CF_PASSWORD},,$(error Must specify CF_PASSWORD)) + $(if ${CF_SPACE},,$(error Must specify CF_SPACE)) + @echo "Logging in to Cloud Foundry on ${CF_API}" + @cf login -a "${CF_API}" -u ${CF_USERNAME} -p "${CF_PASSWORD}" -o "${CF_ORG}" -s "${CF_SPACE}" + +.PHONY: cf-deploy-api +cf-deploy-api: ## Deploys the API to Cloud Foundry + $(eval export ORIG_INSTANCES=$(shell cf curl /v2/apps/$(shell cf app --guid notify-api) | jq -r ".entity.instances")) + @echo "Original instance count: ${ORIG_INSTANCES}" + cf check-manifest notify-api -f manifest-api-${CF_SPACE}.yml + cf zero-downtime-push notify-api -f manifest-api-${CF_SPACE}.yml + cf scale -i ${ORIG_INSTANCES} notify-api + +.PHONY: cf-push-api +cf-push-api: ## + cf push notify-api -f manifest-api-${CF_SPACE}.yml + +.PHONY: cf-deploy-api-db-migration +cf-deploy-api-db-migration: ## Deploys the API db migration to Cloud Foundry + cf check-manifest notify-api-db-migration -f manifest-api-db-migration.yml + cf push notify-api-db-migration -f manifest-api-db-migration.yml + +cf-push-api-db-migration: cf-deploy-api-db-migration ## Deploys the API db migration to Cloud Foundry + +.PHONY: cf-deploy-delivery +cf-deploy-delivery: ## Deploys a delivery app to Cloud Foundry + $(if ${CF_APP},,$(error Must specify CF_APP)) + $(eval export ORIG_INSTANCES=$(shell cf curl /v2/apps/$(shell cf app --guid ${CF_APP}) | jq -r ".entity.instances")) + @echo "Original instance count: ${ORIG_INSTANCES}" + cf check-manifest ${CF_APP} -f manifest-$(subst notify-,,${CF_APP}).yml + cf zero-downtime-push ${CF_APP} -f manifest-$(subst notify-,,${CF_APP}).yml + cf scale -i ${ORIG_INSTANCES} ${CF_APP} + +.PHONY: cf-push-delivery +cf-push-delivery: ## Deploys a delivery app to Cloud Foundry + $(if ${CF_APP},,$(error Must specify CF_APP)) + cf push ${CF_APP} -f manifest-$(subst notify-,,${CF_APP}).yml + +define cf_deploy_with_docker + @docker run -i --rm \ + --name "${DOCKER_CONTAINER_PREFIX}-${1}" \ + -v `pwd`:/var/project \ + -e http_proxy="${HTTP_PROXY}" \ + -e HTTP_PROXY="${HTTP_PROXY}" \ + -e https_proxy="${HTTPS_PROXY}" \ + -e HTTPS_PROXY="${HTTPS_PROXY}" \ + -e NO_PROXY="${NO_PROXY}" \ + -e CF_API="${CF_API}" \ + -e CF_USERNAME="${CF_USERNAME}" \ + -e CF_PASSWORD="${CF_PASSWORD}" \ + -e CF_ORG="${CF_ORG}" \ + -e CF_SPACE="${CF_SPACE}" \ + -e CF_APP="${CF_APP}" \ + ${DOCKER_BUILDER_IMAGE_NAME} \ + ${2} +endef + +.PHONY: cf-deploy-api-with-docker +cf-deploy-api-with-docker: prepare-docker-build-image ## Deploys the API to Cloud Foundry from a Docker container + $(call cf_deploy_with_docker,cf-deploy-api,make cf-login cf-deploy-api) + +.PHONY: cf-deploy-api-db-migration-with-docker +cf-deploy-api-db-migration-with-docker: prepare-docker-build-image ## Deploys the API db migration to Cloud Foundry from a Docker container + $(call cf_deploy_with_docker,cf-deploy-api-db-migration,make cf-login cf-deploy-api-db-migration) + +.PHONY: cf-deploy-delivery-with-docker +cf-deploy-delivery-with-docker: prepare-docker-build-image ## Deploys a delivery app to Cloud Foundry from a Docker container + $(if ${CF_APP},,$(error Must specify CF_APP)) + $(call cf_deploy_with_docker,cf-deploy-delivery-${CF_APP},make cf-login cf-deploy-delivery) diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..985278646 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +version.py diff --git a/app/__init__.py b/app/__init__.py index 4184e80d2..a13dacd8b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,6 @@ import os import uuid +import json from flask import Flask, _request_ctx_stack from flask import request, url_for, g, jsonify @@ -42,8 +43,12 @@ api_user = LocalProxy(lambda: _request_ctx_stack.top.api_user) def create_app(app_name=None): application = Flask(__name__) - from config import configs - application.config.from_object(configs[os.environ['NOTIFY_ENVIRONMENT']]) + from app.config import configs + + notify_environment = os.environ['NOTIFY_ENVIRONMENT'] + + application.config.from_object(configs[notify_environment]) + if app_name: application.config['NOTIFY_APP_NAME'] = app_name diff --git a/app/cloudfoundry_config.py b/app/cloudfoundry_config.py new file mode 100644 index 000000000..ed8f7f350 --- /dev/null +++ b/app/cloudfoundry_config.py @@ -0,0 +1,62 @@ +""" +Extracts cloudfoundry config from its json and populates the environment variables that we would expect to be populated +on local/aws boxes +""" + +import os +import json + + +def extract_cloudfoundry_config(): + vcap_services = json.loads(os.environ['VCAP_SERVICES']) + set_config_env_vars(vcap_services) + + +def set_config_env_vars(vcap_services): + # Postgres config + os.environ['SQLALCHEMY_DATABASE_URI'] = vcap_services['postgres'][0]['credentials']['uri'] + + vcap_application = json.loads(os.environ['VCAP_APPLICATION']) + os.environ['NOTIFY_ENVIRONMENT'] = vcap_application['space_name'] + os.environ['LOGGING_STDOUT_JSON'] = '1' + + # Notify common config + for s in vcap_services['user-provided']: + if s['name'] == 'notify-config': + extract_notify_config(s) + elif s['name'] == 'notify-aws': + extract_notify_aws_config(s) + elif s['name'] == 'hosted-graphite': + extract_hosted_graphite_config(s) + elif s['name'] == 'mmg': + extract_mmg_config(s) + elif s['name'] == 'firetext': + extract_firetext_config(s) + + +def extract_notify_config(notify_config): + os.environ['ADMIN_BASE_URL'] = notify_config['credentials']['admin_base_url'] + os.environ['API_HOST_NAME'] = notify_config['credentials']['api_host_name'] + os.environ['ADMIN_CLIENT_SECRET'] = notify_config['credentials']['admin_client_secret'] + os.environ['SECRET_KEY'] = notify_config['credentials']['secret_key'] + os.environ['DANGEROUS_SALT'] = notify_config['credentials']['dangerous_salt'] + + +def extract_notify_aws_config(aws_config): + os.environ['NOTIFICATION_QUEUE_PREFIX'] = aws_config['credentials']['sqs_queue_prefix'] + os.environ['AWS_ACCESS_KEY_ID'] = aws_config['credentials']['aws_access_key_id'] + os.environ['AWS_SECRET_ACCESS_KEY'] = aws_config['credentials']['aws_secret_access_key'] + + +def extract_hosted_graphite_config(hosted_graphite_config): + os.environ['STATSD_PREFIX'] = hosted_graphite_config['credentials']['statsd_prefix'] + + +def extract_mmg_config(mmg_config): + os.environ['MMG_URL'] = mmg_config['credentials']['api_url'] + os.environ['MMG_API_KEY'] = mmg_config['credentials']['api_key'] + + +def extract_firetext_config(firetext_config): + os.environ['FIRETEXT_API_KEY'] = firetext_config['credentials']['api_key'] + os.environ['LOADTESTING_API_KEY'] = firetext_config['credentials']['loadtesting_api_key'] diff --git a/config.py b/app/config.py similarity index 89% rename from config.py rename to app/config.py index 884fed7eb..21b1c1a53 100644 --- a/config.py +++ b/app/config.py @@ -4,14 +4,20 @@ from kombu import Exchange, Queue import os -class Config(object): - ######################################## - # Secrets that are held in credstash ### - ######################################## +if os.environ.get('VCAP_SERVICES'): + # on cloudfoundry, config is a json blob in VCAP_SERVICES - unpack it, and populate + # standard environment variables from it + from app.cloudfoundry_config import extract_cloudfoundry_config + extract_cloudfoundry_config() + +class Config(object): # URL of admin app ADMIN_BASE_URL = os.environ['ADMIN_BASE_URL'] + # URL of api app (on AWS this is the internal api endpoint) + API_HOST_NAME = os.getenv('API_HOST_NAME') + # admin app api key ADMIN_CLIENT_SECRET = os.environ['ADMIN_CLIENT_SECRET'] @@ -42,12 +48,16 @@ class Config(object): # URL of redis instance REDIS_URL = os.getenv('REDIS_URL') + REDIS_ENABLED = os.getenv('REDIS_ENABLED') == '1' + + # Logging + DEBUG = False + LOGGING_STDOUT_JSON = os.getenv('LOGGING_STDOUT_JSON') == '1' ########################### # Default config values ### ########################### - DEBUG = False NOTIFY_ENVIRONMENT = 'development' ADMIN_CLIENT_USER_NAME = 'notify-admin' AWS_REGION = 'eu-west-1' @@ -126,8 +136,6 @@ class Config(object): Queue('notify', Exchange('default'), routing_key='notify') ] - API_HOST_NAME = "http://localhost:6011" - NOTIFICATIONS_ALERT = 5 # five mins FROM_NUMBER = 'development' @@ -135,8 +143,6 @@ class Config(object): STATSD_HOST = "statsd.hostedgraphite.com" STATSD_PORT = 8125 - REDIS_ENABLED = False - SENDING_NOTIFICATIONS_TIMEOUT_PERIOD = 259200 # 3 days SIMULATED_EMAIL_ADDRESSES = ('simulate-delivered@notifications.service.gov.uk', @@ -165,6 +171,7 @@ class Development(Config): Queue('send-email', Exchange('default'), routing_key='send-email'), Queue('research-mode', Exchange('default'), routing_key='research-mode') ] + API_HOST_NAME = "http://localhost:6011" class Test(Config): @@ -172,7 +179,6 @@ class Test(Config): FROM_NUMBER = 'testing' NOTIFY_ENVIRONMENT = 'test' DEBUG = True - REDIS_ENABLED = True CSV_UPLOAD_BUCKET_NAME = 'test-notifications-csv-upload' STATSD_ENABLED = True STATSD_HOST = "localhost" @@ -184,6 +190,8 @@ class Test(Config): Queue('send-email', Exchange('default'), routing_key='send-email'), Queue('research-mode', Exchange('default'), routing_key='research-mode') ] + REDIS_ENABLED = True + API_HOST_NAME = "http://localhost:6011" class Preview(Config): @@ -192,7 +200,6 @@ class Preview(Config): CSV_UPLOAD_BUCKET_NAME = 'preview-notifications-csv-upload' API_HOST_NAME = 'http://admin-api.internal' FROM_NUMBER = 'preview' - REDIS_ENABLED = True class Staging(Config): @@ -202,7 +209,6 @@ class Staging(Config): STATSD_ENABLED = True API_HOST_NAME = 'http://admin-api.internal' FROM_NUMBER = 'stage' - REDIS_ENABLED = True class Live(Config): @@ -212,7 +218,18 @@ class Live(Config): STATSD_ENABLED = True API_HOST_NAME = 'http://admin-api.internal' FROM_NUMBER = '40604' - REDIS_ENABLED = True + + +class CloudFoundryConfig(Config): + pass + + +# CloudFoundry sandbox +class Sandbox(CloudFoundryConfig): + NOTIFY_EMAIL_DOMAIN = 'notify.works' + NOTIFY_ENVIRONMENT = 'sandbox' + CSV_UPLOAD_BUCKET_NAME = 'cf-sandbox-notifications-csv-upload' + FROM_NUMBER = 'sandbox' configs = { @@ -220,5 +237,6 @@ configs = { 'test': Test, 'live': Live, 'staging': Staging, - 'preview': Preview + 'preview': Preview, + 'sandbox': Sandbox } diff --git a/aws_run_celery.py b/aws_run_celery.py index e1db882a2..36a1f480a 100644 --- a/aws_run_celery.py +++ b/aws_run_celery.py @@ -3,8 +3,9 @@ from app import notify_celery, create_app from credstash import getAllSecrets import os -# on aws get secrets and export to env -os.environ.update(getAllSecrets(region="eu-west-1")) +# On AWS get secrets and export to env, skip this on Cloud Foundry +if os.getenv('VCAP_SERVICES') is None: + os.environ.update(getAllSecrets(region="eu-west-1")) application = create_app("delivery") application.app_context().push() diff --git a/db.py b/db.py index 7ded5ba1e..3a8da01d4 100644 --- a/db.py +++ b/db.py @@ -4,8 +4,9 @@ from app import create_app, db from credstash import getAllSecrets import os -# on aws get secrets and export to env -os.environ.update(getAllSecrets(region="eu-west-1")) +# On AWS get secrets and export to env, skip this on Cloud Foundry +if os.getenv('VCAP_SERVICES') is None: + os.environ.update(getAllSecrets(region="eu-west-1")) application = create_app() diff --git a/docker/Dockerfile-build b/docker/Dockerfile similarity index 60% rename from docker/Dockerfile-build rename to docker/Dockerfile index b8d145cb5..019e74168 100644 --- a/docker/Dockerfile-build +++ b/docker/Dockerfile @@ -13,10 +13,19 @@ RUN \ && apt-get update \ && apt-get install -y --no-install-recommends \ make \ + curl \ git \ build-essential \ zip \ libpq-dev \ + jq \ + + && echo "Install Cloud Foundry CLI" \ + && curl -sSL "https://cli.run.pivotal.io/stable?release=debian64&source=github" -o /tmp/cloudfoundry-cli.deb \ + && dpkg -i /tmp/cloudfoundry-cli.deb \ + && cf install-plugin -r CF-Community -f "autopilot" \ + && cf install-plugin -r CF-Community -f "blue-green-deploy" \ + && cf install-plugin -r CF-Community -f "antifreeze" \ && echo "Clean up" \ && rm -rf /var/lib/apt/lists/* /tmp/* diff --git a/docker/Makefile b/docker/Makefile index ab9f9d444..9a9bb76c9 100644 --- a/docker/Makefile +++ b/docker/Makefile @@ -1,17 +1,23 @@ .DEFAULT_GOAL := help SHELL := /bin/bash +DOCKER_IMAGE_TAG := $(shell cat VERSION) .PHONY: help help: @cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -.PHONY: build-build-image -build-build-image: +.PHONY: build +build: docker build \ --pull \ --build-arg HTTP_PROXY="${HTTP_PROXY}" \ --build-arg HTTPS_PROXY="${HTTP_PROXY}" \ --build-arg NO_PROXY="${NO_PROXY}" \ - -f Dockerfile-build \ - -t govuk/notify-api-builder \ + -t govuk/notify-api-builder:${DOCKER_IMAGE_TAG} \ . + +.PHONY: bash +bash: + docker run -it --rm \ + govuk/notify-api-builder \ + bash diff --git a/docker/VERSION b/docker/VERSION new file mode 100644 index 000000000..0cfbf0888 --- /dev/null +++ b/docker/VERSION @@ -0,0 +1 @@ +2 diff --git a/manifest-api-db-migration.yml b/manifest-api-db-migration.yml new file mode 100644 index 000000000..5760e111d --- /dev/null +++ b/manifest-api-db-migration.yml @@ -0,0 +1,19 @@ +--- + +applications: + - name: notify-api-db-migration + buildpack: python_buildpack + command: python db.py db upgrade && sleep infinity + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + env: + NOTIFY_APP_NAME: public-api + no-route: true + health-check-type: none + instances: 1 + memory: 128M diff --git a/manifest-api-preview.yml b/manifest-api-preview.yml new file mode 100644 index 000000000..e6645a9e2 --- /dev/null +++ b/manifest-api-preview.yml @@ -0,0 +1,20 @@ +--- + +applications: + - name: notify-api + buildpack: python_buildpack + command: gunicorn -w 5 -b 0.0.0.0:$PORT wsgi + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + env: + NOTIFY_APP_NAME: public-api + routes: + - route: notify-api-preview.cloudapps.digital + - route: api-paas.notify.works + instances: 1 + memory: 512M diff --git a/manifest-api-production.yml b/manifest-api-production.yml new file mode 100644 index 000000000..9ea72569e --- /dev/null +++ b/manifest-api-production.yml @@ -0,0 +1,20 @@ +--- + +applications: + - name: notify-api + buildpack: python_buildpack + command: gunicorn -w 5 -b 0.0.0.0:$PORT wsgi + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + env: + NOTIFY_APP_NAME: public-api + routes: + - route: notify-api-production.cloudapps.digital + - route: api-paas.notifications.service.gov.uk + instances: 2 + memory: 2048M diff --git a/manifest-api-sandbox.yml b/manifest-api-sandbox.yml new file mode 100644 index 000000000..fc6e117dc --- /dev/null +++ b/manifest-api-sandbox.yml @@ -0,0 +1,19 @@ +--- + +applications: + - name: notify-api + buildpack: python_buildpack + command: gunicorn -w 5 -b 0.0.0.0:$PORT wsgi + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + env: + NOTIFY_APP_NAME: public-api + routes: + - route: notify-api-sandbox.cloudapps.digital + instances: 1 + memory: 512M diff --git a/manifest-api-staging.yml b/manifest-api-staging.yml new file mode 100644 index 000000000..e593f1e26 --- /dev/null +++ b/manifest-api-staging.yml @@ -0,0 +1,20 @@ +--- + +applications: + - name: notify-api + buildpack: python_buildpack + command: gunicorn -w 5 -b 0.0.0.0:$PORT wsgi + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + env: + NOTIFY_APP_NAME: public-api + routes: + - route: notify-api-staging.cloudapps.digital + - route: api-paas.staging-notify.works + instances: 2 + memory: 2048M diff --git a/manifest-delivery-celery-beat.yml b/manifest-delivery-celery-beat.yml new file mode 100644 index 000000000..0f65fd635 --- /dev/null +++ b/manifest-delivery-celery-beat.yml @@ -0,0 +1,19 @@ +--- + +applications: + - name: notify-delivery-celery-beat + buildpack: python_buildpack + health-check-type: none + no-route: true + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + instances: 2 + memory: 128M + command: celery -A aws_run_celery.notify_celery beat --loglevel=INFO + env: + NOTIFY_APP_NAME: delivery-celery-beat diff --git a/manifest-delivery-worker-database.yml b/manifest-delivery-worker-database.yml new file mode 100644 index 000000000..eaff9e337 --- /dev/null +++ b/manifest-delivery-worker-database.yml @@ -0,0 +1,19 @@ +--- + +applications: + - name: notify-delivery-worker-database + buildpack: python_buildpack + health-check-type: none + no-route: true + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + instances: 2 + memory: 256M + command: celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=11 -Q db-sms,db-email + env: + NOTIFY_APP_NAME: delivery-worker-database diff --git a/manifest-delivery-worker-research.yml b/manifest-delivery-worker-research.yml new file mode 100644 index 000000000..cf935aac9 --- /dev/null +++ b/manifest-delivery-worker-research.yml @@ -0,0 +1,19 @@ +--- + +applications: + - name: notify-delivery-worker-research + buildpack: python_buildpack + health-check-type: none + no-route: true + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + instances: 2 + memory: 256M + command: celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=5 -Q research-mode + env: + NOTIFY_APP_NAME: delivery-worker-research diff --git a/manifest-delivery-worker-sender.yml b/manifest-delivery-worker-sender.yml new file mode 100644 index 000000000..3774c86ff --- /dev/null +++ b/manifest-delivery-worker-sender.yml @@ -0,0 +1,19 @@ +--- + +applications: + - name: notify-delivery-worker-sender + buildpack: python_buildpack + health-check-type: none + no-route: true + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + instances: 2 + memory: 256M + command: celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=11 -Q send-sms,send-email + env: + NOTIFY_APP_NAME: delivery-worker-sender diff --git a/manifest-delivery-worker.yml b/manifest-delivery-worker.yml new file mode 100644 index 000000000..ee53bb82a --- /dev/null +++ b/manifest-delivery-worker.yml @@ -0,0 +1,19 @@ +--- + +applications: + - name: notify-delivery-worker + buildpack: python_buildpack + health-check-type: none + no-route: true + services: + - notify-aws + - notify-config + - notify-db + - mmg + - firetext + - hosted-graphite + instances: 2 + memory: 256M + command: celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=11 + env: + NOTIFY_APP_NAME: delivery-worker diff --git a/requirements.txt b/requirements.txt index ab817c0e1..067ddb3dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,10 +19,11 @@ monotonic==1.2 statsd==3.2.1 jsonschema==2.5.1 Flask-Redis==0.1.0 +gunicorn==19.6.0 # pin to minor version 3.1.x notifications-python-client>=3.1,<3.2 -git+https://github.com/alphagov/notifications-utils.git@13.0.1#egg=notifications-utils==13.0.1 +git+https://github.com/alphagov/notifications-utils.git@13.1.0#egg=notifications-utils==13.1.0 git+https://github.com/alphagov/boto.git@2.43.0-patch3#egg=boto==2.43.0-patch3 diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 000000000..c0354eefe --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.5.2 diff --git a/server_commands.py b/server_commands.py index 0b72ee425..6289da765 100644 --- a/server_commands.py +++ b/server_commands.py @@ -11,10 +11,11 @@ if os.path.isfile(default_env_file): with open(default_env_file, 'r') as environment_file: environment = environment_file.readline().strip() -# on aws get secrets and export to env -os.environ.update(getAllSecrets(region="eu-west-1")) +# On AWS get secrets and export to env, skip this on Cloud Foundry +if os.getenv('VCAP_SERVICES') is None: + os.environ.update(getAllSecrets(region="eu-west-1")) -from config import configs +from app.config import configs os.environ['NOTIFY_API_ENVIRONMENT'] = configs[environment] diff --git a/tests/app/test_cloudfoundry_config.py b/tests/app/test_cloudfoundry_config.py new file mode 100644 index 000000000..9b29d90ef --- /dev/null +++ b/tests/app/test_cloudfoundry_config.py @@ -0,0 +1,167 @@ +import os +import json + +import pytest + +from app.cloudfoundry_config import extract_cloudfoundry_config, set_config_env_vars + + +@pytest.fixture +def notify_config(): + return { + 'name': 'notify-config', + 'credentials': { + 'admin_base_url': 'admin base url', + 'api_host_name': 'api host name', + 'admin_client_secret': 'admin client secret', + 'secret_key': 'secret key', + 'dangerous_salt': 'dangerous salt', + } + } + + +@pytest.fixture +def aws_config(): + return { + 'name': 'notify-aws', + 'credentials': { + 'sqs_queue_prefix': 'sqs queue prefix', + 'aws_access_key_id': 'aws access key id', + 'aws_secret_access_key': 'aws secret access key', + } + } + + +@pytest.fixture +def hosted_graphite_config(): + return { + 'name': 'hosted-graphite', + 'credentials': { + 'statsd_prefix': 'statsd prefix' + } + } + + +@pytest.fixture +def mmg_config(): + return { + 'name': 'mmg', + 'credentials': { + 'api_url': 'mmg api url', + 'api_key': 'mmg api key' + } + } + + +@pytest.fixture +def firetext_config(): + return { + 'name': 'firetext', + 'credentials': { + 'api_key': 'firetext api key', + 'loadtesting_api_key': 'loadtesting api key' + } + } + + +@pytest.fixture +def postgres_config(): + return [ + { + 'credentials': { + 'uri': 'postgres uri' + } + } + ] + + +@pytest.fixture +def cloudfoundry_config( + postgres_config, + notify_config, + aws_config, + hosted_graphite_config, + mmg_config, + firetext_config +): + return { + 'postgres': postgres_config, + 'user-provided': [ + notify_config, + aws_config, + hosted_graphite_config, + mmg_config, + firetext_config + ] + } + + +@pytest.fixture +def cloudfoundry_environ(monkeypatch, cloudfoundry_config): + monkeypatch.setenv('VCAP_SERVICES', json.dumps(cloudfoundry_config)) + monkeypatch.setenv('VCAP_APPLICATION', '{"space_name": "🚀🌌"}') + + +@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') +def test_extract_cloudfoundry_config_populates_other_vars(): + extract_cloudfoundry_config() + + assert os.environ['SQLALCHEMY_DATABASE_URI'] == 'postgres uri' + assert os.environ['LOGGING_STDOUT_JSON'] == '1' + assert os.environ['NOTIFY_ENVIRONMENT'] == '🚀🌌' + + +@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') +def test_set_config_env_vars_ignores_unknown_configs(cloudfoundry_config): + cloudfoundry_config['foo'] = {'credentials': {'foo': 'foo'}} + cloudfoundry_config['user-provided'].append({ + 'name': 'bar', 'credentials': {'bar': 'bar'} + }) + + set_config_env_vars(cloudfoundry_config) + + assert 'foo' not in os.environ + assert 'bar' not in os.environ + + +@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') +def test_notify_config(): + extract_cloudfoundry_config() + + assert os.environ['ADMIN_BASE_URL'] == 'admin base url' + assert os.environ['API_HOST_NAME'] == 'api host name' + assert os.environ['ADMIN_CLIENT_SECRET'] == 'admin client secret' + assert os.environ['SECRET_KEY'] == 'secret key' + assert os.environ['DANGEROUS_SALT'] == 'dangerous salt' + + +@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') +def test_aws_config(): + extract_cloudfoundry_config() + + assert os.environ['NOTIFICATION_QUEUE_PREFIX'] == 'sqs queue prefix' + assert os.environ['AWS_ACCESS_KEY_ID'] == 'aws access key id' + assert os.environ['AWS_SECRET_ACCESS_KEY'] == 'aws secret access key' + + +@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') +def test_hosted_graphite_config(): + extract_cloudfoundry_config() + + assert os.environ['STATSD_PREFIX'] == 'statsd prefix' + + +@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') +def test_mmg_config(): + extract_cloudfoundry_config() + + assert os.environ['MMG_URL'] == 'mmg api url' + assert os.environ['MMG_API_KEY'] == 'mmg api key' + + +@pytest.mark.usefixtures('os_environ', 'cloudfoundry_environ') +def test_firetext_config(): + extract_cloudfoundry_config() + + assert os.environ['FIRETEXT_API_KEY'] == 'firetext api key' + assert os.environ['LOADTESTING_API_KEY'] == 'loadtesting api key' diff --git a/tests/app/test_config.py b/tests/app/test_config.py new file mode 100644 index 000000000..63a9bff9a --- /dev/null +++ b/tests/app/test_config.py @@ -0,0 +1,80 @@ +import os +import importlib +from unittest import mock + +import pytest + +from app import config + + +def cf_conf(): + os.environ['ADMIN_BASE_URL'] = 'cf' + + +@pytest.fixture +def reload_config(): + """ + Reset config, by simply re-running config.py from a fresh environment + """ + old_env = os.environ.copy() + + yield + + os.environ = old_env + importlib.reload(config) + + +def test_load_cloudfoundry_config_if_available(monkeypatch, reload_config): + os.environ['ADMIN_BASE_URL'] = 'env' + monkeypatch.setenv('VCAP_SERVICES', 'some json blob') + monkeypatch.setenv('VCAP_APPLICATION', 'some json blob') + + with mock.patch('app.cloudfoundry_config.extract_cloudfoundry_config', side_effect=cf_conf) as cf_config: + # reload config so that its module level code (ie: all of it) is re-instantiated + importlib.reload(config) + + assert cf_config.called + + assert os.environ['ADMIN_BASE_URL'] == 'cf' + assert config.Config.ADMIN_BASE_URL == 'cf' + + +def test_load_config_if_cloudfoundry_not_available(monkeypatch, reload_config): + os.environ['ADMIN_BASE_URL'] = 'env' + + monkeypatch.delenv('VCAP_SERVICES', raising=False) + + with mock.patch('app.cloudfoundry_config.extract_cloudfoundry_config') as cf_config: + # reload config so that its module level code (ie: all of it) is re-instantiated + importlib.reload(config) + + assert not cf_config.called + + assert os.environ['ADMIN_BASE_URL'] == 'env' + assert config.Config.ADMIN_BASE_URL == 'env' + + +def test_cloudfoundry_config_has_different_defaults(): + # these should always be set on Sandbox + assert config.Sandbox.REDIS_ENABLED is False + + +def test_logging_stdout_json_defaults_to_off(reload_config): + os.environ.pop('LOGGING_STDOUT_JSON', None) + assert config.Config.LOGGING_STDOUT_JSON is False + + +def test_logging_stdout_json_sets_to_off_if_not_recognised(reload_config): + os.environ['LOGGING_STDOUT_JSON'] = 'foo' + + importlib.reload(config) + + assert config.Config.LOGGING_STDOUT_JSON is False + + +def test_logging_stdout_json_sets_to_on_if_set_to_1(reload_config): + os.environ['LOGGING_STDOUT_JSON'] = '1' + + importlib.reload(config) + + assert config.Config.LOGGING_STDOUT_JSON is True diff --git a/tests/conftest.py b/tests/conftest.py index 13e705880..ae9583ec3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,9 +75,16 @@ def notify_db_session(notify_db): notify_db.session.commit() -@pytest.fixture(scope='function') -def os_environ(mocker): - mocker.patch('os.environ', {}) +@pytest.fixture +def os_environ(): + """ + clear os.environ, and restore it after the test runs + """ + # for use whenever you expect code to edit environment variables + old_env = os.environ.copy() + os.environ = {} + yield + os.environ = old_env @pytest.fixture(scope='function') diff --git a/wsgi.py b/wsgi.py index e78f4c136..2df2c3976 100644 --- a/wsgi.py +++ b/wsgi.py @@ -4,8 +4,9 @@ from app import create_app from credstash import getAllSecrets -# on aws get secrets and export to env -os.environ.update(getAllSecrets(region="eu-west-1")) +# On AWS get secrets and export to env, skip this on Cloud Foundry +if os.getenv('VCAP_SERVICES') is None: + os.environ.update(getAllSecrets(region="eu-west-1")) application = create_app() From c9f3005392f798150364014b9c43d42bde7f5335 Mon Sep 17 00:00:00 2001 From: bandesz Date: Mon, 9 Jan 2017 10:31:45 +0000 Subject: [PATCH 2/5] Add quotes around Docker volume definitions --- Makefile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 2fb2068dc..b9fc6905e 100644 --- a/Makefile +++ b/Makefile @@ -130,8 +130,8 @@ prepare-docker-build-image: ## Prepare the Docker builder image build-with-docker: prepare-docker-build-image ## Build inside a Docker container @docker run -i --rm \ --name "${DOCKER_CONTAINER_PREFIX}-build" \ - -v `pwd`:/var/project \ - -v ${PIP_ACCEL_CACHE}:/var/project/cache/pip-accel \ + -v "`pwd`:/var/project" \ + -v "${PIP_ACCEL_CACHE}:/var/project/cache/pip-accel" \ -e GIT_COMMIT=${GIT_COMMIT} \ -e BUILD_NUMBER=${BUILD_NUMBER} \ -e BUILD_URL=${BUILD_URL} \ @@ -174,7 +174,7 @@ test-with-docker: prepare-docker-build-image create-docker-test-db ## Run tests -e https_proxy="${HTTPS_PROXY}" \ -e HTTPS_PROXY="${HTTPS_PROXY}" \ -e NO_PROXY="${NO_PROXY}" \ - -v `pwd`:/var/project \ + -v "`pwd`:/var/project" \ ${DOCKER_BUILDER_IMAGE_NAME} \ make test @@ -193,7 +193,7 @@ create-docker-test-db: ## Start the test database in a Docker container coverage-with-docker: prepare-docker-build-image ## Generates coverage report inside a Docker container @docker run -i --rm \ --name "${DOCKER_CONTAINER_PREFIX}-coverage" \ - -v `pwd`:/var/project \ + -v "`pwd`:/var/project" \ -e COVERALLS_REPO_TOKEN=${COVERALLS_REPO_TOKEN} \ -e CIRCLECI=1 \ -e CI_NAME=${CI_NAME} \ @@ -261,7 +261,7 @@ cf-push-delivery: ## Deploys a delivery app to Cloud Foundry define cf_deploy_with_docker @docker run -i --rm \ --name "${DOCKER_CONTAINER_PREFIX}-${1}" \ - -v `pwd`:/var/project \ + -v "`pwd`:/var/project" \ -e http_proxy="${HTTP_PROXY}" \ -e HTTP_PROXY="${HTTP_PROXY}" \ -e https_proxy="${HTTPS_PROXY}" \ From 8300cb0ac02d922a911496964d0be7dc4cf37138 Mon Sep 17 00:00:00 2001 From: bandesz Date: Mon, 9 Jan 2017 12:16:00 +0000 Subject: [PATCH 3/5] Add Jenkinsfile --- Jenkinsfile | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 Jenkinsfile diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 000000000..3c1c5b0f5 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,248 @@ +#!groovy + +def deployDatabaseMigrations(cfEnv) { + waitUntil { + try { + lock(cfEnv) { + withCredentials([ + string(credentialsId: 'paas_username', variable: 'CF_USERNAME'), + string(credentialsId: 'paas_password', variable: 'CF_PASSWORD') + ]) { + withEnv(["CF_SPACE=${cfEnv}"]) { + sh 'make cf-deploy-api-db-migration-with-docker' + } + } + } + true + } catch(err) { + echo "Deployment to ${cfEnv} failed: ${err}" + try { + slackSend channel: '#govuk-notify', message: "Deployment to ${cfEnv} failed. Please retry or abort: <${env.BUILD_URL}|${env.JOB_NAME} - #${env.BUILD_NUMBER}>", color: 'danger' + } catch(err2) { + echo "Sending Slack message failed: ${err2}" + } + input "Stage failed. Retry?" + false + } + } +} + +def deploy(cfEnv) { + waitUntil { + try { + lock(cfEnv) { + withCredentials([ + string(credentialsId: 'paas_username', variable: 'CF_USERNAME'), + string(credentialsId: 'paas_password', variable: 'CF_PASSWORD') + ]) { + withEnv(["CF_SPACE=${cfEnv}"]) { + parallel deployApi: { + retry(3) { + sh 'make cf-deploy-api-with-docker' + } + }, deployDeliveryCeleryBeat: { + sleep(10) + withEnv(["CF_APP=notify-delivery-celery-beat"]) { + retry(3) { + sh 'make cf-deploy-delivery-with-docker' + } + } + }, deployDeliveryWorker: { + sleep(20) + withEnv(["CF_APP=notify-delivery-worker"]) { + retry(3) { + sh 'make cf-deploy-delivery-with-docker' + } + } + }, deployDeliveryWorkerSender: { + sleep(30) + withEnv(["CF_APP=notify-delivery-worker-sender"]) { + retry(3) { + sh 'make cf-deploy-delivery-with-docker' + } + } + }, deployDeliveryWorkerDatabase: { + sleep(40) + withEnv(["CF_APP=notify-delivery-worker-database"]) { + retry(3) { + sh 'make cf-deploy-delivery-with-docker' + } + } + }, deployDeliveryWorkerResearch: { + sleep(50) + withEnv(["CF_APP=notify-delivery-worker-research"]) { + retry(3) { + sh 'make cf-deploy-delivery-with-docker' + } + } + } + } + } + gitCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() + sh("git tag -f deployed-to-cf-${cfEnv} ${gitCommit}") + sh("git push -f origin deployed-to-cf-${cfEnv}") + } + true + } catch(err) { + echo "Deployment to ${cfEnv} failed: ${err}" + try { + slackSend channel: '#govuk-notify', message: "Deployment to ${cfEnv} failed. Please retry or abort: <${env.BUILD_URL}|${env.JOB_NAME} - #${env.BUILD_NUMBER}>", color: 'danger' + } catch(err2) { + echo "Sending Slack message failed: ${err2}" + } + input "Stage failed. Retry?" + false + } + } +} + +def buildJobWithRetry(jobName) { + waitUntil { + try { + build job: jobName + true + } catch(err) { + echo "${jobName} failed: ${err}" + try { + slackSend channel: '#govuk-notify', message: "${jobName} failed. Please retry or abort: <${env.BUILD_URL}|${env.JOB_NAME} - #${env.BUILD_NUMBER}>", color: 'danger' + } catch(err2) { + echo "Sending Slack message failed: ${err2}" + } + input "${jobName} failed. Retry?" + false + } + } +} + +try { + node { + stage('Build') { + git url: 'git@github.com:alphagov/notifications-api.git', branch: 'cloudfoundry', credentialsId: 'github_com_and_gds' + checkout scm + + milestone 10 + withEnv(["PIP_ACCEL_CACHE=${env.JENKINS_HOME}/cache/pip-accel"]) { + sh 'make cf-build-with-docker' + } + + stash name: 'source', excludes: 'venv/**,wheelhouse/**', useDefaultExcludes: false + } + + stage('Test') { + milestone 20 + sh 'make test-with-docker' + + try { + junit 'test_results.xml' + } catch(err) { + echo "Collecting jUnit results failed: ${err}" + } + + try { + withCredentials([string(credentialsId: 'coveralls_repo_token_api', variable: 'COVERALLS_REPO_TOKEN')]) { + sh 'make coverage-with-docker' + } + } catch(err) { + echo "Coverage failed: ${err}" + } + } + + stage('Preview') { + if (deployToPreview == "true") { + milestone 30 + deployDatabaseMigrations 'preview' + buildJobWithRetry 'notify-functional-tests-preview' + deploy 'preview' + } else { + echo 'Preview skipped.' + } + } + + stage('Preview tests') { + if (deployToPreview == "true") { + buildJobWithRetry 'notify-functional-tests-preview' + buildJobWithRetry 'run-ruby-client-integration-tests' + buildJobWithRetry 'run-python-client-integration-tests' + buildJobWithRetry 'run-net-client-integration-tests' + buildJobWithRetry 'run-node-client-integration-tests' + buildJobWithRetry 'run-java-client-integration-tests' + buildJobWithRetry 'run-php-client-integration-tests' + } else { + echo 'Preview tests skipped.' + } + } + } + + stage('Staging') { + if (deployToStaging == "true") { + input 'Approve?' + milestone 40 + node { + unstash 'source' + deployDatabaseMigrations 'staging' + buildJobWithRetry 'notify-functional-tests-staging' + deploy 'staging' + } + } else { + echo 'Staging skipped.' + } + } + + stage('Staging tests') { + if (deployToStaging == "true") { + buildJobWithRetry 'notify-functional-tests-staging' + buildJobWithRetry 'notify-functional-provider-tests-staging' + } else { + echo 'Staging tests skipped' + } + } + + stage('Prod') { + if (deployToProduction == "true") { + input 'Approve?' + milestone 50 + node { + unstash 'source' + deployDatabaseMigrations 'production' + buildJobWithRetry 'notify-functional-admin-tests-production' + buildJobWithRetry 'notify-functional-api-email-test-production' + buildJobWithRetry 'notify-functional-api-sms-test-production' + deploy 'production' + } + } else { + echo 'Production skipped.' + } + } + + stage('Prod tests') { + if (deployToProduction == "true") { + buildJobWithRetry 'notify-functional-admin-tests-production' + buildJobWithRetry 'notify-functional-api-email-test-production' + buildJobWithRetry 'notify-functional-api-sms-test-production' + buildJobWithRetry 'notify-functional-provider-email-test-production' + buildJobWithRetry 'notify-functional-provider-sms-test-production' + } else { + echo 'Production tests skipped.' + } + } +} catch (org.jenkinsci.plugins.workflow.steps.FlowInterruptedException fie) { + currentBuild.result = 'ABORTED' +} catch (err) { + currentBuild.result = 'FAILURE' + echo "Pipeline failed: ${err}" + slackSend channel: '#govuk-notify', message: "${env.JOB_NAME} - #${env.BUILD_NUMBER} failed (<${env.BUILD_URL}|Open>)", color: 'danger' +} finally { + node { + try { + step([$class: 'Mailer', notifyEveryUnstableBuild: true, recipients: 'notify-support+jenkins@digital.cabinet-office.gov.uk', sendToIndividuals: false]) + } catch(err) { + echo "Sending email failed: ${err}" + } + + try { + sh 'make clean-docker-containers' + } catch(err) { + echo "Cleaning up Docker containers failed: ${err}" + } + } +} From ac0e1f1defadb9f017f3306c93fe5fbb98e23565 Mon Sep 17 00:00:00 2001 From: bandesz Date: Thu, 12 Jan 2017 14:38:08 +0000 Subject: [PATCH 4/5] Add gosu and host user to Docker --- Makefile | 29 ++++++++++++++++++++--------- docker/Dockerfile | 36 +++++++++++++++++++++++++++--------- docker/Makefile | 12 +++++++++++- docker/entrypoint.sh | 33 +++++++++++++++++++++++++++++++++ docker/tianon.gpg | Bin 0 -> 61602 bytes 5 files changed, 91 insertions(+), 19 deletions(-) create mode 100755 docker/entrypoint.sh create mode 100644 docker/tianon.gpg diff --git a/Makefile b/Makefile index b9fc6905e..1a40c67dc 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ GIT_COMMIT ?= $(shell git rev-parse HEAD) DOCKER_IMAGE_TAG := $(shell cat docker/VERSION) DOCKER_BUILDER_IMAGE_NAME = govuk/notify-api-builder:${DOCKER_IMAGE_TAG} +DOCKER_TTY ?= $(if ${JENKINS_HOME},,t) BUILD_TAG ?= notifications-api-manual BUILD_NUMBER ?= 0 @@ -128,10 +129,12 @@ prepare-docker-build-image: ## Prepare the Docker builder image .PHONY: build-with-docker build-with-docker: prepare-docker-build-image ## Build inside a Docker container - @docker run -i --rm \ + @docker run -i${DOCKER_TTY} --rm \ --name "${DOCKER_CONTAINER_PREFIX}-build" \ -v "`pwd`:/var/project" \ -v "${PIP_ACCEL_CACHE}:/var/project/cache/pip-accel" \ + -e UID=$(shell id -u) \ + -e GID=$(shell id -g) \ -e GIT_COMMIT=${GIT_COMMIT} \ -e BUILD_NUMBER=${BUILD_NUMBER} \ -e BUILD_URL=${BUILD_URL} \ @@ -141,14 +144,16 @@ build-with-docker: prepare-docker-build-image ## Build inside a Docker container -e HTTPS_PROXY="${HTTPS_PROXY}" \ -e NO_PROXY="${NO_PROXY}" \ ${DOCKER_BUILDER_IMAGE_NAME} \ - make build + gosu hostuser make build .PHONY: cf-build-with-docker cf-build-with-docker: prepare-docker-build-image ## Build inside a Docker container - @docker run -i --rm \ + @docker run -i${DOCKER_TTY} --rm \ --name "${DOCKER_CONTAINER_PREFIX}-build" \ -v "`pwd`:/var/project" \ -v "${PIP_ACCEL_CACHE}:/var/project/cache/pip-accel" \ + -e UID=$(shell id -u) \ + -e GID=$(shell id -g) \ -e GIT_COMMIT=${GIT_COMMIT} \ -e BUILD_NUMBER=${BUILD_NUMBER} \ -e BUILD_URL=${BUILD_URL} \ @@ -158,13 +163,15 @@ cf-build-with-docker: prepare-docker-build-image ## Build inside a Docker contai -e HTTPS_PROXY="${HTTPS_PROXY}" \ -e NO_PROXY="${NO_PROXY}" \ ${DOCKER_BUILDER_IMAGE_NAME} \ - make cf-build + gosu hostuser make cf-build .PHONY: test-with-docker test-with-docker: prepare-docker-build-image create-docker-test-db ## Run tests inside a Docker container - @docker run -i --rm \ + @docker run -i${DOCKER_TTY} --rm \ --name "${DOCKER_CONTAINER_PREFIX}-test" \ --link "${DOCKER_CONTAINER_PREFIX}-db:postgres" \ + -e UID=$(shell id -u) \ + -e GID=$(shell id -g) \ -e TEST_DATABASE=postgresql://postgres:postgres@postgres/test_notification_api \ -e GIT_COMMIT=${GIT_COMMIT} \ -e BUILD_NUMBER=${BUILD_NUMBER} \ @@ -176,7 +183,7 @@ test-with-docker: prepare-docker-build-image create-docker-test-db ## Run tests -e NO_PROXY="${NO_PROXY}" \ -v "`pwd`:/var/project" \ ${DOCKER_BUILDER_IMAGE_NAME} \ - make test + gosu hostuser make test .PHONY: test-with-docker create-docker-test-db: ## Start the test database in a Docker container @@ -191,9 +198,11 @@ create-docker-test-db: ## Start the test database in a Docker container # FIXME: CIRCLECI=1 is an ugly hack because the coveralls-python library sends the PR link only this way .PHONY: coverage-with-docker coverage-with-docker: prepare-docker-build-image ## Generates coverage report inside a Docker container - @docker run -i --rm \ + @docker run -i${DOCKER_TTY} --rm \ --name "${DOCKER_CONTAINER_PREFIX}-coverage" \ -v "`pwd`:/var/project" \ + -e UID=$(shell id -u) \ + -e GID=$(shell id -g) \ -e COVERALLS_REPO_TOKEN=${COVERALLS_REPO_TOKEN} \ -e CIRCLECI=1 \ -e CI_NAME=${CI_NAME} \ @@ -207,7 +216,7 @@ coverage-with-docker: prepare-docker-build-image ## Generates coverage report in -e HTTPS_PROXY="${HTTPS_PROXY}" \ -e NO_PROXY="${NO_PROXY}" \ ${DOCKER_BUILDER_IMAGE_NAME} \ - make coverage + gosu hostuser make coverage .PHONY: clean-docker-containers clean-docker-containers: ## Clean up any remaining docker containers @@ -259,9 +268,11 @@ cf-push-delivery: ## Deploys a delivery app to Cloud Foundry cf push ${CF_APP} -f manifest-$(subst notify-,,${CF_APP}).yml define cf_deploy_with_docker - @docker run -i --rm \ + @docker run -i${DOCKER_TTY} --rm \ --name "${DOCKER_CONTAINER_PREFIX}-${1}" \ -v "`pwd`:/var/project" \ + -e UID=$(shell id -u) \ + -e GID=$(shell id -g) \ -e http_proxy="${HTTP_PROXY}" \ -e HTTP_PROXY="${HTTP_PROXY}" \ -e https_proxy="${HTTPS_PROXY}" \ diff --git a/docker/Dockerfile b/docker/Dockerfile index 019e74168..c7daf00bc 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -5,7 +5,8 @@ ARG HTTPS_PROXY ARG NO_PROXY ENV PYTHONUNBUFFERED=1 \ - DEBIAN_FRONTEND=noninteractive + DEBIAN_FRONTEND=noninteractive \ + GOSU_VERSION=1.10 RUN \ echo "Install base packages" \ @@ -19,14 +20,6 @@ RUN \ zip \ libpq-dev \ jq \ - - && echo "Install Cloud Foundry CLI" \ - && curl -sSL "https://cli.run.pivotal.io/stable?release=debian64&source=github" -o /tmp/cloudfoundry-cli.deb \ - && dpkg -i /tmp/cloudfoundry-cli.deb \ - && cf install-plugin -r CF-Community -f "autopilot" \ - && cf install-plugin -r CF-Community -f "blue-green-deploy" \ - && cf install-plugin -r CF-Community -f "antifreeze" \ - && echo "Clean up" \ && rm -rf /var/lib/apt/lists/* /tmp/* @@ -37,4 +30,29 @@ RUN \ awscli \ wheel +RUN \ + echo "Install Cloud Foundry CLI" \ + && curl -sSL "https://cli.run.pivotal.io/stable?release=debian64&source=github" -o /tmp/cloudfoundry-cli.deb \ + && dpkg -i /tmp/cloudfoundry-cli.deb \ + && cf install-plugin -r CF-Community -f "autopilot" \ + && cf install-plugin -r CF-Community -f "blue-green-deploy" \ + && cf install-plugin -r CF-Community -f "antifreeze" + +COPY tianon.gpg /tmp/tianon.gpg + +RUN \ + echo "Install gosu" \ + && curl -sSL -o /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$(dpkg --print-architecture)" \ + && curl -sSL -o /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$(dpkg --print-architecture).asc" \ + && export GNUPGHOME="$(mktemp -d)" \ + && gpg --import /tmp/tianon.gpg \ + && gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu \ + && rm -r "$GNUPGHOME" /usr/local/bin/gosu.asc \ + && chmod +x /usr/local/bin/gosu \ + && gosu nobody true + WORKDIR /var/project + +COPY entrypoint.sh /usr/local/bin/docker-entrypoint + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint"] diff --git a/docker/Makefile b/docker/Makefile index 9a9bb76c9..9cf6a6162 100644 --- a/docker/Makefile +++ b/docker/Makefile @@ -19,5 +19,15 @@ build: .PHONY: bash bash: docker run -it --rm \ - govuk/notify-api-builder \ + -e UID=$(shell id -u) \ + -e GID=$(shell id -g) \ + govuk/notify-api-builder:${DOCKER_IMAGE_TAG} \ bash + +.PHONY: bash +bash-hostuser: + docker run -it --rm \ + -e UID=$(shell id -u) \ + -e GID=$(shell id -g) \ + govuk/notify-api-builder:${DOCKER_IMAGE_TAG} \ + gosu hostuser bash diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..a57515244 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -eo pipefail; [[ "$TRACE" ]] && set -x + +if [[ "$(id -u)" -ne 0 ]]; then + echo 'docker-entrypoint requires root' >&2 + exit 1 +fi + +if [ -z "$UID" ] || [ "$UID" = "0" ]; then + echo "UID must be specified as a positive integer" + exit 1 +fi + +if [ -z "$GID" ] || [ "$GID" = "0" ]; then + echo "GID must be specified as positive integer" + exit 1 +fi + +USER=$(id -un $UID 2>/dev/null || echo "hostuser") +GROUP=$(getent group $GID | cut -d: -f1 || echo "hostgroup") + +if [ "$USER" = "hostuser" ]; then + useradd -u $UID -s /bin/bash -m $USER +fi + +if [ "$GROUP" = "hostgroup" ]; then + groupadd -g $GID $GROUP +fi + +usermod -g $GROUP $USER + +exec "$@" diff --git a/docker/tianon.gpg b/docker/tianon.gpg new file mode 100644 index 0000000000000000000000000000000000000000..415d549079b141d4bf41be213006657b699ccd8b GIT binary patch literal 61602 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

4Ph;HgWP(l~$U*-rvJK9V^;$ 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 O3DZj$^<$K`0{$1kdt_b! literal 0 HcmV?d00001 From e9a90b46b0f055e56f1b6782f8fd8ee41be7d305 Mon Sep 17 00:00:00 2001 From: bandesz Date: Mon, 16 Jan 2017 17:06:26 +0000 Subject: [PATCH 5/5] Add Makefile commands to control autoscaling processes --- Makefile | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Makefile b/Makefile index f90dd940a..63b89d31a 100644 --- a/Makefile +++ b/Makefile @@ -103,6 +103,26 @@ deploy-admin-api: check-env-vars ## Trigger CodeDeploy for the admin api deploy-delivery: check-env-vars ## Trigger CodeDeploy for the delivery app aws deploy create-deployment --application-name notify-delivery --deployment-config-name CodeDeployDefault.OneAtATime --deployment-group-name notify-delivery --s3-location bucket=${DNS_NAME}-codedeploy,key=notifications-api-${DEPLOY_BUILD_NUMBER}.zip,bundleType=zip --region eu-west-1 +.PHONY: check-aws-vars +check-aws-vars: ## Check if AWS access keys are set + $(if ${AWS_ACCESS_KEY_ID},,$(error Must specify AWS_ACCESS_KEY_ID)) + $(if ${AWS_SECRET_ACCESS_KEY},,$(error Must specify AWS_SECRET_ACCESS_KEY)) + +.PHONY: deploy-suspend-autoscaling-processes +deploy-suspend-autoscaling-processes: check-aws-vars ## Suspend launch and terminate processes for the auto-scaling group + $(if ${CODEDEPLOY_APP_NAME},,$(error Must specify CODEDEPLOY_APP_NAME)) + aws autoscaling suspend-processes --region eu-west-1 --auto-scaling-group-name ${CODEDEPLOY_APP_NAME} --scaling-processes "Launch" "Terminate" + +.PHONY: deploy-resume-autoscaling-processes +deploy-resume-autoscaling-processes: check-aws-vars ## Resume launch and terminate processes for the auto-scaling group + $(if ${CODEDEPLOY_APP_NAME},,$(error Must specify CODEDEPLOY_APP_NAME)) + aws autoscaling resume-processes --region eu-west-1 --auto-scaling-group-name ${CODEDEPLOY_APP_NAME} --scaling-processes "Launch" "Terminate" + +.PHONY: deploy-check-autoscaling-processes +deploy-check-autoscaling-processes: check-aws-vars ## Returns with the number of instances with active autoscaling events + $(if ${CODEDEPLOY_APP_NAME},,$(error Must specify CODEDEPLOY_APP_NAME)) + @aws autoscaling describe-auto-scaling-groups --region eu-west-1 --auto-scaling-group-names ${CODEDEPLOY_APP_NAME} | jq '.AutoScalingGroups[0].Instances|map(select(.LifecycleState != "InService"))|length' + .PHONY: coverage coverage: venv ## Create coverage report ./venv/bin/coveralls