From 5fd3b0c40de69622f4b825c0b3755049604041ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Oct 2019 22:50:39 +0000 Subject: [PATCH 1/6] Bump pillow from 6.1.0 to 6.2.0 in /report-api Bumps [pillow](https://github.com/python-pillow/Pillow) from 6.1.0 to 6.2.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/master/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/6.1.0...6.2.0) Signed-off-by: dependabot[bot] --- report-api/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/report-api/requirements.txt b/report-api/requirements.txt index 9e76aedc2..5531986b3 100644 --- a/report-api/requirements.txt +++ b/report-api/requirements.txt @@ -9,7 +9,7 @@ Flask==1.1.1 Jinja2==2.10.1 Mako==1.1.0 MarkupSafe==1.1.1 -Pillow==6.1.0 +Pillow==6.2.0 Pyphen==0.9.5 SQLAlchemy==1.3.8 WeasyPrint==47 From 03d3f861cff47d841300b82bf1638b151f2757a1 Mon Sep 17 00:00:00 2001 From: Sumesh Punakkal Kariyil Date: Fri, 18 Oct 2019 15:59:27 -0700 Subject: [PATCH 2/6] Initial commit for BCOL Service Initial commit for BCOL Service Initial commit for BCOL Service Bcol service changes Bcol service changes Adding cert ignore Adding cert ignore Add TLS never to ldap config Bcol service changes Bcol service changes Commenting out test pipeline Bcol service changes Bcol service changes Changes for testing and linting --- bcol-api/.s2i/environment | 1 + bcol-api/LICENSE | 13 + bcol-api/MANIFEST.in | 6 + bcol-api/Makefile | 155 +++++++++ bcol-api/README.md | 77 +++++ bcol-api/config.py | 168 ++++++++++ bcol-api/entrypoint.sh | 5 + bcol-api/gunicorn_config.py | 25 ++ bcol-api/jenkins/dev.groovy | 236 ++++++++++++++ bcol-api/jenkins/prod.groovy | 121 +++++++ bcol-api/jenkins/staging.groovy | 237 ++++++++++++++ bcol-api/jenkins/test.groovy | 121 +++++++ bcol-api/logging.conf | 28 ++ bcol-api/manage.py | 36 +++ .../openshift/templates/bcol-api-build.json | 137 ++++++++ .../templates/bcol-api-deploy.e2e.json | 306 ++++++++++++++++++ .../openshift/templates/bcol-api-deploy.json | 306 ++++++++++++++++++ .../templates/bcol-api-pipeline.json | 113 +++++++ bcol-api/requirements.txt | 50 +++ bcol-api/requirements/dev.txt | 23 ++ bcol-api/requirements/prod.txt | 18 ++ bcol-api/requirements/repo-libraries.txt | 2 + bcol-api/scripts/verify_license_headers.sh | 30 ++ bcol-api/setup.cfg | 116 +++++++ bcol-api/setup.py | 61 ++++ bcol-api/src/bcol_api/__init__.py | 84 +++++ bcol-api/src/bcol_api/exceptions/__init__.py | 34 ++ bcol-api/src/bcol_api/resources/__init__.py | 66 ++++ bcol-api/src/bcol_api/resources/apihelper.py | 29 ++ .../src/bcol_api/resources/bcol_profile.py | 54 ++++ bcol-api/src/bcol_api/resources/meta.py | 35 ++ bcol-api/src/bcol_api/resources/ops.py | 43 +++ bcol-api/src/bcol_api/schemas/__init__.py | 14 + .../schemas/schemas/accounts_request.json | 23 ++ bcol-api/src/bcol_api/schemas/utils.py | 93 ++++++ bcol-api/src/bcol_api/services/__init__.py | 14 + .../src/bcol_api/services/bcol_service.py | 131 ++++++++ bcol-api/src/bcol_api/services/bcol_soap.py | 45 +++ bcol-api/src/bcol_api/utils/__init__.py | 14 + bcol-api/src/bcol_api/utils/auth.py | 19 ++ bcol-api/src/bcol_api/utils/constants.py | 42 +++ bcol-api/src/bcol_api/utils/errors.py | 30 ++ bcol-api/src/bcol_api/utils/logging.py | 31 ++ bcol-api/src/bcol_api/utils/run_version.py | 29 ++ bcol-api/src/bcol_api/utils/trace.py | 22 ++ bcol-api/src/bcol_api/utils/util.py | 33 ++ bcol-api/src/bcol_api/version.py | 25 ++ bcol-api/tests/__init__.py | 16 + bcol-api/tests/conftest.py | 137 ++++++++ bcol-api/tests/unit/__init__.py | 18 ++ bcol-api/tests/unit/api/__init__.py | 14 + bcol-api/tests/unit/api/test_bcol_profile.py | 53 +++ bcol-api/tests/unit/api/test_meta.py | 42 +++ bcol-api/tests/unit/api/test_ops.py | 35 ++ bcol-api/tests/unit/conf/__init__.py | 14 + .../tests/unit/conf/test_configuration.py | 66 ++++ bcol-api/tests/unit/conf/test_version.py | 28 ++ .../tests/unit/services/test_bcol_service.py | 27 ++ .../tests/unit/services/test_bcol_soap.py | 36 +++ bcol-api/tests/unit/utils/__init__.py | 14 + bcol-api/tests/unit/utils/logging.conf | 28 ++ bcol-api/tests/unit/utils/test_logging.py | 42 +++ bcol-api/tests/unit/utils/test_util_cors.py | 45 +++ bcol-api/tests/utilities/__init__.py | 15 + bcol-api/tests/utilities/base_test.py | 48 +++ bcol-api/tests/utilities/decorators.py | 24 ++ bcol-api/tests/utilities/ldap_mock.py | 45 +++ bcol-api/tests/utilities/schema_assertions.py | 37 +++ bcol-api/wsgi.py | 22 ++ 69 files changed, 4107 insertions(+) create mode 100644 bcol-api/.s2i/environment create mode 100755 bcol-api/LICENSE create mode 100755 bcol-api/MANIFEST.in create mode 100755 bcol-api/Makefile create mode 100755 bcol-api/README.md create mode 100755 bcol-api/config.py create mode 100755 bcol-api/entrypoint.sh create mode 100755 bcol-api/gunicorn_config.py create mode 100644 bcol-api/jenkins/dev.groovy create mode 100644 bcol-api/jenkins/prod.groovy create mode 100644 bcol-api/jenkins/staging.groovy create mode 100644 bcol-api/jenkins/test.groovy create mode 100755 bcol-api/logging.conf create mode 100755 bcol-api/manage.py create mode 100644 bcol-api/openshift/templates/bcol-api-build.json create mode 100644 bcol-api/openshift/templates/bcol-api-deploy.e2e.json create mode 100644 bcol-api/openshift/templates/bcol-api-deploy.json create mode 100644 bcol-api/openshift/templates/bcol-api-pipeline.json create mode 100755 bcol-api/requirements.txt create mode 100755 bcol-api/requirements/dev.txt create mode 100755 bcol-api/requirements/prod.txt create mode 100644 bcol-api/requirements/repo-libraries.txt create mode 100755 bcol-api/scripts/verify_license_headers.sh create mode 100755 bcol-api/setup.cfg create mode 100755 bcol-api/setup.py create mode 100755 bcol-api/src/bcol_api/__init__.py create mode 100755 bcol-api/src/bcol_api/exceptions/__init__.py create mode 100755 bcol-api/src/bcol_api/resources/__init__.py create mode 100644 bcol-api/src/bcol_api/resources/apihelper.py create mode 100755 bcol-api/src/bcol_api/resources/bcol_profile.py create mode 100755 bcol-api/src/bcol_api/resources/meta.py create mode 100755 bcol-api/src/bcol_api/resources/ops.py create mode 100644 bcol-api/src/bcol_api/schemas/__init__.py create mode 100644 bcol-api/src/bcol_api/schemas/schemas/accounts_request.json create mode 100644 bcol-api/src/bcol_api/schemas/utils.py create mode 100755 bcol-api/src/bcol_api/services/__init__.py create mode 100644 bcol-api/src/bcol_api/services/bcol_service.py create mode 100644 bcol-api/src/bcol_api/services/bcol_soap.py create mode 100755 bcol-api/src/bcol_api/utils/__init__.py create mode 100644 bcol-api/src/bcol_api/utils/auth.py create mode 100644 bcol-api/src/bcol_api/utils/constants.py create mode 100644 bcol-api/src/bcol_api/utils/errors.py create mode 100755 bcol-api/src/bcol_api/utils/logging.py create mode 100755 bcol-api/src/bcol_api/utils/run_version.py create mode 100644 bcol-api/src/bcol_api/utils/trace.py create mode 100755 bcol-api/src/bcol_api/utils/util.py create mode 100755 bcol-api/src/bcol_api/version.py create mode 100755 bcol-api/tests/__init__.py create mode 100755 bcol-api/tests/conftest.py create mode 100755 bcol-api/tests/unit/__init__.py create mode 100755 bcol-api/tests/unit/api/__init__.py create mode 100755 bcol-api/tests/unit/api/test_bcol_profile.py create mode 100755 bcol-api/tests/unit/api/test_meta.py create mode 100755 bcol-api/tests/unit/api/test_ops.py create mode 100755 bcol-api/tests/unit/conf/__init__.py create mode 100755 bcol-api/tests/unit/conf/test_configuration.py create mode 100755 bcol-api/tests/unit/conf/test_version.py create mode 100644 bcol-api/tests/unit/services/test_bcol_service.py create mode 100644 bcol-api/tests/unit/services/test_bcol_soap.py create mode 100755 bcol-api/tests/unit/utils/__init__.py create mode 100755 bcol-api/tests/unit/utils/logging.conf create mode 100755 bcol-api/tests/unit/utils/test_logging.py create mode 100755 bcol-api/tests/unit/utils/test_util_cors.py create mode 100644 bcol-api/tests/utilities/__init__.py create mode 100644 bcol-api/tests/utilities/base_test.py create mode 100644 bcol-api/tests/utilities/decorators.py create mode 100644 bcol-api/tests/utilities/ldap_mock.py create mode 100755 bcol-api/tests/utilities/schema_assertions.py create mode 100755 bcol-api/wsgi.py diff --git a/bcol-api/.s2i/environment b/bcol-api/.s2i/environment new file mode 100644 index 000000000..55c62ce9c --- /dev/null +++ b/bcol-api/.s2i/environment @@ -0,0 +1 @@ +APP_CONFIG=gunicorn_config.py diff --git a/bcol-api/LICENSE b/bcol-api/LICENSE new file mode 100755 index 000000000..18b5abc34 --- /dev/null +++ b/bcol-api/LICENSE @@ -0,0 +1,13 @@ +Copyright © 2018 Province of British Columbia + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/bcol-api/MANIFEST.in b/bcol-api/MANIFEST.in new file mode 100755 index 000000000..8d820bc4e --- /dev/null +++ b/bcol-api/MANIFEST.in @@ -0,0 +1,6 @@ +include requirements/prod.txt +include config.py +include logging.conf +include LICENSE +include README.md +include src/bcol_api/schemas/schemas/*.json diff --git a/bcol-api/Makefile b/bcol-api/Makefile new file mode 100755 index 000000000..b6ae67e85 --- /dev/null +++ b/bcol-api/Makefile @@ -0,0 +1,155 @@ +.PHONY: license +.PHONY: setup clean clean-build clean-pyc clean-test + +.PHONY: docker-setup network build start qa style safety test test-travis flake8 \ +isort isort-save stop docker-clean logs +.PHONY: mac-cov pylint flake8 + +SHELL:=/bin/bash +mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST))) +current_dir := $(notdir $(patsubst %/,%,$(dir $(mkfile_path)))) +current_abs_dir := $(patsubst %/,%,$(dir $(mkfile_path))) + +################################################################################# +# COMMANDS # +################################################################################# +clean: clean-build clean-pyc clean-test + rm -rf venv/ + +clean-build: + rm -fr build/ + rm -fr dist/ + rm -fr .eggs/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -f {} + + +clean-pyc: + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + +clean-test: + find . -name '.pytest_cache' -exec rm -fr {} + + rm -fr .tox/ + rm -f .coverage + rm -fr htmlcov/ + +setup: clean venv/bin/activate install-dev + +venv/bin/activate: requirements/prod.txt requirements/dev.txt + rm -rf venv/ + test -f venv/bin/activate || python3 -m venv $(current_abs_dir)/venv + . venv/bin/activate ;\ + pip install --upgrade pip ;\ + pip install -Ur requirements/prod.txt ;\ + pip freeze | sort > requirements.txt ;\ + cat requirements/repo-libraries.txt >> requirements.txt ;\ + pip install -Ur requirements/repo-libraries.txt ;\ + pip install -Ur requirements/dev.txt + touch venv/bin/activate # update so it's as new as requirements/prod.txt + +.PHONY: install-dev +install-dev: venv/bin/activate + . venv/bin/activate ; \ + pip install -e . + +.PHONY: activate +activate: venv/bin/activate + . venv/bin/activate + +.PHONY: local-test +local-test: venv/bin/activate + . venv/bin/activate ; \ + pytest + +.PHONY: local-coverage +local-coverage: venv/bin/activate + . venv/bin/activate ; \ + coverage run -m pytest + +.PHONY: coverage-report +coverage-report: local-coverage + . venv/bin/activate ; \ + coverage report ; \ + coverage html + +## Run the coverage report and display in a browser window +mac-cov: install-dev coverage-report + open -a "Google Chrome" htmlcov/index.html + +## run pylint on the package and tests +pylint: + pylint --rcfile=setup.cfg \ + --load-plugins=pylint_flask \ + --disable=C0301,W0511 \ + src/bcol_api + +## run flake8 on the package and tests +flake8: + flake8 src/bcol_api tests + +## Verify source code license headers. +license: + ./scripts/verify_license_headers.sh src/bcol_api tests + +################################################################################# +# Self Documenting Commands # +################################################################################# + +.DEFAULT_GOAL := show-help + +# Inspired by +# sed script explained: +# /^##/: +# * save line in hold space +# * purge line +# * Loop: +# * append newline + line to hold space +# * go to next line +# * if line starts with doc comment, strip comment character off and loop +# * remove target prerequisites +# * append hold space (+ newline) to line +# * replace newline plus comments by `---` +# * print line +# Separate expressions are necessary because labels cannot be delimited by +# semicolon; see +.PHONY: show-help +show-help: + @echo "$$(tput bold)Available rules:$$(tput sgr0)" + @echo + @sed -n -e "/^## / { \ + h; \ + s/.*//; \ + :doc" \ + -e "H; \ + n; \ + s/^## //; \ + t doc" \ + -e "s/:.*//; \ + G; \ + s/\\n## /---/; \ + s/\\n/ /g; \ + p; \ + }" ${MAKEFILE_LIST} \ + | LC_ALL='C' sort --ignore-case \ + | awk -F '---' \ + -v ncol=$$(tput cols) \ + -v indent=19 \ + -v col_on="$$(tput setaf 6)" \ + -v col_off="$$(tput sgr0)" \ + '{ \ + printf "%s%*s%s ", col_on, -indent, $$1, col_off; \ + n = split($$2, words, " "); \ + line_length = ncol - indent; \ + for (i = 1; i <= n; i++) { \ + line_length -= length(words[i]) + 1; \ + if (line_length <= 0) { \ + line_length = ncol - indent - length(words[i]) - 1; \ + printf "\n%*s ", -indent, " "; \ + } \ + printf "%s ", words[i]; \ + } \ + printf "\n"; \ + }' \ + | more $(shell test $(shell uname) = Darwin && echo '--no-init --raw-control-chars') diff --git a/bcol-api/README.md b/bcol-api/README.md new file mode 100755 index 000000000..bfb30fa16 --- /dev/null +++ b/bcol-api/README.md @@ -0,0 +1,77 @@ + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) + +[![Bugs](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/api/badges/measure?key=BCRegistriesPayment&metric=bugs&template=FLAT)](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/dashboard?id=BCRegistriesPayment) [![Vulnerabilities](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/api/badges/measure?key=BCRegistriesPayment&metric=vulnerabilities&template=FLAT)](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/dashboard?id=BCRegistriesPayment) [![Code smells](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/api/badges/measure?key=BCRegistriesPayment&metric=code_smells&template=FLAT)](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/dashboard?id=BCRegistriesPayment) [![Coverage](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/api/badges/measure?key=BCRegistriesPayment&metric=coverage&template=FLAT)](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/dashboard?id=BCRegistriesPayment) [![Duplication](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/api/badges/measure?key=BCRegistriesPayment&metric=duplicated_lines_density&template=FLAT)](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/dashboard?id=BCRegistriesPayment) [![Lines of code](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/api/badges/measure?key=BCRegistriesPayment&metric=lines&template=FLAT)](https://sonarqube-l4ygcl-tools.pathfinder.gov.bc.ca/dashboard?id=BCRegistriesPayment) + +# BC Registries Payment System + +## Technology Stack Used +* Python, Flask +* Postgres - SQLAlchemy, psycopg2-binary & alembic + +## Third-Party Products/Libraries used and the the License they are covert by + +## Project Status + +## Documentation + +GitHub Pages (https://guides.github.com/features/pages/) are a neat way to document you application/project. + +## Security + +Future - BCGov Keycloak + +Current - JWT hack + +## Files in this repository + +``` +docs/ - Project Documentation +└── images +└── icons + +openshift/ - OpenShift-specific files +├── scripts - helper scripts +└── templates - application templates +``` + +## Deployment (Local Development) + +* Developer Workstation Requirements/Setup +* Application Specific Setup + +## Deployment (OpenShift) + +See (openshift/Readme.md) + +## Getting Help or Reporting an Issue + +To report bugs/issues/feature requests, please file an [issue](../../issues). + + +## Code standards + +Refer [checklist](https://github.com/bcgov/sbc-auth/wiki/API-code-review-checklist) + +## How to Contribute + +If you would like to contribute, please see our [CONTRIBUTING](./CONTRIBUTING.md) guidelines. + +Please note that this project is released with a [Contributor Code of Conduct](./CODE_OF_CONDUCT.md). +By participating in this project you agree to abide by its terms. + +## License + + Copyright 2018 Province of British Columbia + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/bcol-api/config.py b/bcol-api/config.py new file mode 100755 index 000000000..a72a4e922 --- /dev/null +++ b/bcol-api/config.py @@ -0,0 +1,168 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""All of the configuration for the service is captured here. All items are loaded, or have Constants defined here that are loaded into the Flask configuration. All modules and lookups get their configuration from the Flask config, rather than reading environment variables directly or by accessing this configuration directly. +""" + +import os +import sys +import json + +from dotenv import find_dotenv, load_dotenv + +# this will load all the envars from a .env file located in the project root (api) +load_dotenv(find_dotenv()) + +CONFIGURATION = { + 'development': 'config.DevConfig', + 'testing': 'config.TestConfig', + 'production': 'config.ProdConfig', + 'default': 'config.ProdConfig', +} + + +def get_named_config(config_name: str = 'production'): + """Return the configuration object based on the name + + :raise: KeyError: if an unknown configuration is requested + """ + if config_name in ['production', 'staging', 'default']: + config = ProdConfig() + elif config_name == 'testing': + config = TestConfig() + elif config_name == 'development': + config = DevConfig() + else: + raise KeyError(f"Unknown configuration '{config_name}'") + return config + +def _get_config(config_key: str, **kwargs): + """Get the config from environment, and throw error if there are no default values and if the value is None.""" + if 'default' in kwargs: + value = os.getenv(config_key, kwargs.get('default')) + else: + value = os.getenv(config_key) + assert value + return value + +class _Config(object): # pylint: disable=too-few-public-methods + """Base class configuration that should set reasonable defaults for all the other configurations. """ + + PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) + + SECRET_KEY = 'a secret' + + SQLALCHEMY_TRACK_MODIFICATIONS = False + + ALEMBIC_INI = 'migrations/alembic.ini' + + # JWT_OIDC Settings + JWT_OIDC_WELL_KNOWN_CONFIG = _get_config('JWT_OIDC_WELL_KNOWN_CONFIG') + JWT_OIDC_ALGORITHMS = _get_config('JWT_OIDC_ALGORITHMS') + JWT_OIDC_ISSUER = _get_config('JWT_OIDC_ISSUER') + JWT_OIDC_AUDIENCE = _get_config('JWT_OIDC_AUDIENCE') + JWT_OIDC_CLIENT_SECRET = _get_config('JWT_OIDC_CLIENT_SECRET') + JWT_OIDC_CACHING_ENABLED = _get_config('JWT_OIDC_CACHING_ENABLED', default=False) + JWT_OIDC_JWKS_CACHE_TIMEOUT = int(_get_config('JWT_OIDC_JWKS_CACHE_TIMEOUT', default=300)) + + # BCOL + BCOL_VERIFY_USER_WSDL_URL = _get_config('BCOL_VERIFY_USER_WSDL_URL') + BCOL_QUERY_PROFILE_WSDL_URL = _get_config('BCOL_QUERY_PROFILE_WSDL_URL') + BCOL_LDAP_SERVER = _get_config('BCOL_LDAP_SERVER') + BCOL_LDAP_USER_DN_PATTERN = _get_config('BCOL_LDAP_USER_DN_PATTERN') + BCOL_DEBIT_ACCOUNT_VERSION = _get_config('BCOL_DEBIT_ACCOUNT_VERSION') + BCOL_LINK_CODE = _get_config('BCOL_LINK_CODE') + # Sentry Config + SENTRY_DSN = _get_config('SENTRY_DSN', default=None) + + TESTING = False + DEBUG = True + + +class DevConfig(_Config): # pylint: disable=too-few-public-methods + TESTING = False + DEBUG = True + + +class TestConfig(_Config): # pylint: disable=too-few-public-methods + """In support of testing only used by the py.test suite.""" + + DEBUG = True + TESTING = True + + JWT_OIDC_TEST_MODE = True + JWT_OIDC_TEST_AUDIENCE = os.getenv('JWT_OIDC_AUDIENCE') + JWT_OIDC_TEST_CLIENT_SECRET = os.getenv('JWT_OIDC_CLIENT_SECRET') + JWT_OIDC_TEST_ISSUER = os.getenv('JWT_OIDC_ISSUER') + JWT_OIDC_TEST_KEYS = { + 'keys': [ + { + 'kid': 'sbc-auth-cron-job', + 'kty': 'RSA', + 'alg': 'RS256', + 'use': 'sig', + 'n': 'AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR', + 'e': 'AQAB', + } + ] + } + + JWT_OIDC_TEST_PRIVATE_KEY_JWKS = { + 'keys': [ + { + 'kid': 'sbc-auth-cron-job', + 'kty': 'RSA', + 'alg': 'RS256', + 'use': 'sig', + 'n': 'AN-fWcpCyE5KPzHDjigLaSUVZI0uYrcGcc40InVtl-rQRDmAh-C2W8H4_Hxhr5VLc6crsJ2LiJTV_E72S03pzpOOaaYV6-TzAjCou2GYJIXev7f6Hh512PuG5wyxda_TlBSsI-gvphRTPsKCnPutrbiukCYrnPuWxX5_cES9eStR', + 'e': 'AQAB', + 'd': 'C0G3QGI6OQ6tvbCNYGCqq043YI_8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhskURaDwk4-8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh_xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0', + 'p': 'APXcusFMQNHjh6KVD_hOUIw87lvK13WkDEeeuqAydai9Ig9JKEAAfV94W6Aftka7tGgE7ulg1vo3eJoLWJ1zvKM', + 'q': 'AOjX3OnPJnk0ZFUQBwhduCweRi37I6DAdLTnhDvcPTrrNWuKPg9uGwHjzFCJgKd8KBaDQ0X1rZTZLTqi3peT43s', + 'dp': 'AN9kBoA5o6_Rl9zeqdsIdWFmv4DB5lEqlEnC7HlAP-3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhc', + 'dq': 'ANtbSY6njfpPploQsF9sU26U0s7MsuLljM1E8uml8bVJE1mNsiu9MgpUvg39jEu9BtM2tDD7Y51AAIEmIQex1nM', + 'qi': 'XLE5O360x-MhsdFXx8Vwz4304-MJg-oGSJXCK_ZWYOB_FGXFRTfebxCsSYi0YwJo-oNu96bvZCuMplzRI1liZw', + } + ] + } + + JWT_OIDC_TEST_PRIVATE_KEY_PEM = """ + -----BEGIN RSA PRIVATE KEY----- + MIICXQIBAAKBgQDfn1nKQshOSj8xw44oC2klFWSNLmK3BnHONCJ1bZfq0EQ5gIfg + tlvB+Px8Ya+VS3OnK7Cdi4iU1fxO9ktN6c6TjmmmFevk8wIwqLthmCSF3r+3+h4e + ddj7hucMsXWv05QUrCPoL6YUUz7Cgpz7ra24rpAmK5z7lsV+f3BEvXkrUQIDAQAB + AoGAC0G3QGI6OQ6tvbCNYGCqq043YI/8MiBl7C5dqbGZmx1ewdJBhMNJPStuckhs + kURaDwk4+8VBW9SlvcfSJJrnZhgFMjOYSSsBtPGBIMIdM5eSKbenCCjO8Tg0BUh/ + xa3CHST1W4RQ5rFXadZ9AeNtaGcWj2acmXNO3DVETXAX3x0CQQD13LrBTEDR44ei + lQ/4TlCMPO5bytd1pAxHnrqgMnWovSIPSShAAH1feFugH7ZGu7RoBO7pYNb6N3ia + C1idc7yjAkEA6Nfc6c8meTRkVRAHCF24LB5GLfsjoMB0tOeEO9w9Ous1a4o+D24b + AePMUImAp3woFoNDRfWtlNktOqLel5PjewJBAN9kBoA5o6/Rl9zeqdsIdWFmv4DB + 5lEqlEnC7HlAP+3oo3jWFO9KQqArQL1V8w2D4aCd0uJULiC9pCP7aTHvBhcCQQDb + W0mOp436T6ZaELBfbFNulNLOzLLi5YzNRPLppfG1SRNZjbIrvTIKVL4N/YxLvQbT + NrQw+2OdQACBJiEHsdZzAkBcsTk7frTH4yGx0VfHxXDPjfTj4wmD6gZIlcIr9lZg + 4H8UZcVFN95vEKxJiLRjAmj6g273pu9kK4ymXNEjWWJn + -----END RSA PRIVATE KEY-----""" + + + +class ProdConfig(_Config): # pylint: disable=too-few-public-methods + """Production environment configuration.""" + + SECRET_KEY = _get_config('SECRET_KEY', default=None) + + if not SECRET_KEY: + SECRET_KEY = os.urandom(24) + print('WARNING: SECRET_KEY being set as a one-shot', file=sys.stderr) + + TESTING = False + DEBUG = False diff --git a/bcol-api/entrypoint.sh b/bcol-api/entrypoint.sh new file mode 100755 index 000000000..1c88d960e --- /dev/null +++ b/bcol-api/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +echo "Waiting for postgres..." + +gunicorn -b 0.0.0.0:5000 wsgi:application diff --git a/bcol-api/gunicorn_config.py b/bcol-api/gunicorn_config.py new file mode 100755 index 000000000..9cb75dd26 --- /dev/null +++ b/bcol-api/gunicorn_config.py @@ -0,0 +1,25 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""The configuration for gunicorn, which picks up the + runtime options from environment variables +""" + +import os + + +workers = int(os.environ.get('GUNICORN_PROCESSES', '1')) # pylint: disable=invalid-name +threads = int(os.environ.get('GUNICORN_THREADS', '1')) # pylint: disable=invalid-name + +forwarded_allow_ips = '*' # pylint: disable=invalid-name +secure_scheme_headers = {'X-Forwarded-Proto': 'https'} # pylint: disable=invalid-name diff --git a/bcol-api/jenkins/dev.groovy b/bcol-api/jenkins/dev.groovy new file mode 100644 index 000000000..54f81d7d3 --- /dev/null +++ b/bcol-api/jenkins/dev.groovy @@ -0,0 +1,236 @@ +#!/usr/bin/env groovy +// Copyright © 2018 Province of British Columbia +// +// 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. +// +//JENKINS DEPLOY ENVIRONMENT VARIABLES: +// - JENKINS_JAVA_OVERRIDES -Dhudson.model.DirectoryBrowserSupport.CSP= -Duser.timezone=America/Vancouver +// -> user.timezone : set the local timezone so logfiles report correxct time +// -> hudson.model.DirectoryBrowserSupport.CSP : removes restrictions on CSS file load, thus html pages of test reports are displayed pretty +// See: https://docs.openshift.com/container-platform/3.9/using_images/other_images/jenkins.html for a complete list of JENKINS env vars + +import groovy.json.* + +// define constants - values sent in as env vars from whatever calls this pipeline +def APP_NAME = 'bcol-api' +def DESTINATION_TAG = 'dev' +def TOOLS_TAG = 'tools' +def NAMESPACE_APP = 'l4ygcl' +def NAMESPACE_SHARED = 'd7eovc' +def NAMESPACE_BUILD = "${NAMESPACE_APP}" + '-' + "${TOOLS_TAG}" +def NAMESPACE_DEPLOY = "${NAMESPACE_APP}" + '-' + "${DESTINATION_TAG}" +def NAMESPACE_UNITTEST = "${NAMESPACE_SHARED}" + '-'+ "${TOOLS_TAG}" + +def ROCKETCHAT_DEVELOPER_CHANNEL='#relationship-developers' + +// post a notification to rocketchat +def rocketChatNotificaiton(token, channel, comments) { + def payload = JsonOutput.toJson([text: comments, channel: channel]) + def rocketChatUrl = "https://chat.pathfinder.gov.bc.ca/hooks/" + "${token}" + + sh(returnStdout: true, + script: "curl -X POST -H 'Content-Type: application/json' --data \'${payload}\' ${rocketChatUrl}") +} + +@NonCPS +boolean triggerBuild(String contextDirectory) { + // Determine if code has changed within the source context directory. + def changeLogSets = currentBuild.changeSets + def filesChangeCnt = 0 + for (int i = 0; i < changeLogSets.size(); i++) { + def entries = changeLogSets[i].items + for (int j = 0; j < entries.length; j++) { + def entry = entries[j] + //echo "${entry.commitId} by ${entry.author} on ${new Date(entry.timestamp)}: ${entry.msg}" + def files = new ArrayList(entry.affectedFiles) + for (int k = 0; k < files.size(); k++) { + def file = files[k] + def filePath = file.path + //echo ">> ${file.path}" + if (filePath.contains(contextDirectory)) { + filesChangeCnt = 1 + k = files.size() + j = entries.length + } + } + } + } + + if ( filesChangeCnt < 1 ) { + echo('The changes do not require a build.') + return false + } else { + echo('The changes require a build.') + return true + } +} + +// Get an image's hash tag +String getImageTagHash(String imageName, String tag = "") { + + if(!tag?.trim()) { + tag = "latest" + } + + def istag = openshift.raw("get istag ${imageName}:${tag} -o template --template='{{.image.dockerImageReference}}'") + return istag.out.tokenize('@')[1].trim() +} + +// define job properties - keep 10 builds only +properties([[$class: 'BuildDiscarderProperty', strategy: [$class: 'LogRotator', artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '10']]]) + +def run_pipeline = true + +// build wasn't triggered by changes so check with user +if( !triggerBuild(APP_NAME) ) { + stage('No changes. Run pipeline?') { + try { + timeout(time: 1, unit: 'DAYS') { + input message: "Run pipeline?", id: "1234"//, submitter: 'admin' + } + } catch (Exception e) { + run_pipeline = false; + } + } +} + +if( run_pipeline ) { + node { + def build_ok = true + def old_version + + stage("Build ${APP_NAME}") { + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_BUILD}") { + try { + echo "Building ${APP_NAME} ..." + def build = openshift.selector("bc", "${APP_NAME}").startBuild() + build.untilEach { + return it.object().status.phase == "Running" + } + build.logs('-f') + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + } + } + } + } + + if (build_ok) { + stage("Tag ${APP_NAME}:${DESTINATION_TAG}") { + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_DEPLOY}") { + old_version = openshift.selector('dc', "${APP_NAME}-${DESTINATION_TAG}").object().status.latestVersion + } + } + openshift.withCluster() { + openshift.withProject("${NAMESPACE_BUILD}") { + try { + echo "Tagging ${APP_NAME} for deployment to ${DESTINATION_TAG} ..." + + // Don't tag with BUILD_ID so the pruner can do it's job; it won't delete tagged images. + // Tag the images for deployment based on the image's hash + def IMAGE_HASH = getImageTagHash("${APP_NAME}") + echo "IMAGE_HASH: ${IMAGE_HASH}" + openshift.tag("${APP_NAME}@${IMAGE_HASH}", "${APP_NAME}:${DESTINATION_TAG}") + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + } + } + } + } + } + + if (build_ok) { + stage("Deploy ${APP_NAME}-${DESTINATION_TAG}") { + sleep 10 + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_DEPLOY}") { + try { + def new_version = openshift.selector('dc', "${APP_NAME}-${DESTINATION_TAG}").object().status.latestVersion + if (new_version == old_version) { + echo "New deployment was not triggered." + } + + def pod_selector = openshift.selector('pod', [ app:"${APP_NAME}-${DESTINATION_TAG}" ]) + pod_selector.untilEach { + deployment = it.objects()[0].metadata.labels.deployment + echo deployment + if (deployment == "${APP_NAME}-${DESTINATION_TAG}-${new_version}" && it.objects()[0].status.phase == 'Running' && it.objects()[0].status.containerStatuses[0].ready) { + return true + } else { + echo "Pod for new deployment not ready" + sleep 5 + return false + } + } + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + } + } + } + } + } + + if (build_ok) { + // try { + // stage("Run tests on ${APP_NAME}:${DESTINATION_TAG}") { + // script { + // openshift.withCluster() { + // openshift.withProject("${NAMESPACE_UNITTEST}") { + // def test_pipeline = openshift.selector('bc', 'pytest-pipeline') + + // test_pipeline.startBuild('--wait=true', "-e=component=${APP_NAME}", "-e=component_tag=${DESTINATION_TAG}", "-e=tag=${DESTINATION_TAG}", "-e=namespace=${NAMESPACE_APP}", "-e=db_type=").logs('-f') + // echo "All tests passed" + // } + // } + // } + // } + // } catch (Exception e) { + // echo e.getMessage() + // echo "Not all tests passed." + // build_ok = false + // } + } + + if (build_ok) { + stage("Run E2E API tests") { + + } + } + + stage("Notify on RocketChat") { + if(build_ok) { + currentBuild.result = "SUCCESS" + } else { + currentBuild.result = "FAILURE" + } + + ROCKETCHAT_TOKEN = sh ( + script: """oc get secret/apitest-secrets -n ${NAMESPACE_BUILD} -o template --template="{{.data.ROCKETCHAT_TOKEN}}" | base64 --decode""", + returnStdout: true).trim() + + rocketChatNotificaiton("${ROCKETCHAT_TOKEN}", "${ROCKETCHAT_DEVELOPER_CHANNEL}", "${APP_NAME} build and deploy to ${DESTINATION_TAG} ${currentBuild.result}!") + } + } +} + diff --git a/bcol-api/jenkins/prod.groovy b/bcol-api/jenkins/prod.groovy new file mode 100644 index 000000000..2e0bd857b --- /dev/null +++ b/bcol-api/jenkins/prod.groovy @@ -0,0 +1,121 @@ +#!/usr/bin/env groovy +// Copyright © 2018 Province of British Columbia +// +// 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. +// +//JENKINS DEPLOY ENVIRONMENT VARIABLES: +// - JENKINS_JAVA_OVERRIDES -Dhudson.model.DirectoryBrowserSupport.CSP= -Duser.timezone=America/Vancouver +// -> user.timezone : set the local timezone so logfiles report correxct time +// -> hudson.model.DirectoryBrowserSupport.CSP : removes restrictions on CSS file load, thus html pages of test reports are displayed pretty +// See: https://docs.openshift.com/container-platform/3.9/using_images/other_images/jenkins.html for a complete list of JENKINS env vars + +import groovy.json.* + +// define constants - values sent in as env vars from whatever calls this pipeline +def APP_NAME = 'bcol-api' +def SOURCE_TAG = 'test' +def DESTINATION_TAG = 'prod' +def TOOLS_TAG = 'tools' + +def NAMESPACE_APP = 'l4ygcl' +def NAMESPACE_BUILD = "${NAMESPACE_APP}" + '-' + "${TOOLS_TAG}" +def NAMESPACE_DEPLOY = "${NAMESPACE_APP}" + '-' + "${DESTINATION_TAG}" + +def ROCKETCHAT_DEVELOPER_CHANNEL='#relationship-developers' + +// post a notification to rocketchat +def rocketChatNotificaiton(token, channel, comments) { + def payload = JsonOutput.toJson([text: comments, channel: channel]) + def rocketChatUrl = "https://chat.pathfinder.gov.bc.ca/hooks/" + "${token}" + + sh(returnStdout: true, + script: "curl -X POST -H 'Content-Type: application/json' --data \'${payload}\' ${rocketChatUrl}") +} + +node { + properties([[$class: 'BuildDiscarderProperty', strategy: [$class: 'LogRotator', artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '10']]]) + + def build_ok = true + def old_version + + try { + stage("Tag ${APP_NAME}:${DESTINATION_TAG}") { + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_DEPLOY}") { + old_version = openshift.selector('dc', "${APP_NAME}-${DESTINATION_TAG}").object().status.latestVersion + } + } + openshift.withCluster() { + openshift.withProject("${NAMESPACE_BUILD}") { + echo "Tagging ${APP_NAME} for deployment to ${DESTINATION_TAG} ..." + openshift.tag("${APP_NAME}:${SOURCE_TAG}", "${APP_NAME}:${DESTINATION_TAG}") + } + } + } + } + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + + + if (build_ok) { + try { + stage("Deploy ${APP_NAME}-${DESTINATION_TAG}") { + sleep 10 + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_DEPLOY}") { + + def new_version = openshift.selector('dc', "${APP_NAME}-${DESTINATION_TAG}").object().status.latestVersion + if (new_version == old_version) { + echo "New deployment was not triggered." + } + + def pod_selector = openshift.selector('pod', [ app:"${APP_NAME}-${DESTINATION_TAG}" ]) + pod_selector.untilEach { + deployment = it.objects()[0].metadata.labels.deployment + echo deployment + if (deployment == "${APP_NAME}-${DESTINATION_TAG}-${new_version}" && it.objects()[0].status.phase == 'Running' && it.objects()[0].status.containerStatuses[0].ready) { + return true + } else { + echo "Pod for new deployment not ready" + sleep 5 + return false + } + } + } + } + } + } + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + } + + stage("Notify on RocketChat") { + if(build_ok) { + currentBuild.result = "SUCCESS" + } else { + currentBuild.result = "FAILURE" + } + + ROCKETCHAT_TOKEN = sh ( + script: """oc get secret/apitest-secrets -n ${NAMESPACE_BUILD} -o template --template="{{.data.ROCKETCHAT_TOKEN}}" | base64 --decode""", + returnStdout: true).trim() + + rocketChatNotificaiton("${ROCKETCHAT_TOKEN}", "${ROCKETCHAT_DEVELOPER_CHANNEL}", "${APP_NAME} build and deploy to ${DESTINATION_TAG} ${currentBuild.result}!") + } +} \ No newline at end of file diff --git a/bcol-api/jenkins/staging.groovy b/bcol-api/jenkins/staging.groovy new file mode 100644 index 000000000..e58ce55a2 --- /dev/null +++ b/bcol-api/jenkins/staging.groovy @@ -0,0 +1,237 @@ +#!/usr/bin/env groovy +// Copyright © 2018 Province of British Columbia +// +// 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. +// +//JENKINS DEPLOY ENVIRONMENT VARIABLES: +// - JENKINS_JAVA_OVERRIDES -Dhudson.model.DirectoryBrowserSupport.CSP= -Duser.timezone=America/Vancouver +// -> user.timezone : set the local timezone so logfiles report correxct time +// -> hudson.model.DirectoryBrowserSupport.CSP : removes restrictions on CSS file load, thus html pages of test reports are displayed pretty +// See: https://docs.openshift.com/container-platform/3.9/using_images/other_images/jenkins.html for a complete list of JENKINS env vars + +import groovy.json.* + +// define constants - values sent in as env vars from whatever calls this pipeline +def APP_NAME = 'bcol-api' +def DESTINATION_TAG = 'staging' +def DESTINATION_ENV_TAG = 'test' +def TOOLS_TAG = 'tools' +def NAMESPACE_APP = 'l4ygcl' +def NAMESPACE_SHARED = 'd7eovc' +def NAMESPACE_BUILD = "${NAMESPACE_APP}" + '-' + "${TOOLS_TAG}" +def NAMESPACE_DEPLOY = "${NAMESPACE_APP}" + '-' + "${DESTINATION_ENV_TAG}" +def NAMESPACE_UNITTEST = "${NAMESPACE_SHARED}" + '-'+ "${TOOLS_TAG}" + +def ROCKETCHAT_DEVELOPER_CHANNEL='#relationship-developers' + +// post a notification to rocketchat +def rocketChatNotificaiton(token, channel, comments) { + def payload = JsonOutput.toJson([text: comments, channel: channel]) + def rocketChatUrl = "https://chat.pathfinder.gov.bc.ca/hooks/" + "${token}" + + sh(returnStdout: true, + script: "curl -X POST -H 'Content-Type: application/json' --data \'${payload}\' ${rocketChatUrl}") +} + +@NonCPS +boolean triggerBuild(String contextDirectory) { + // Determine if code has changed within the source context directory. + def changeLogSets = currentBuild.changeSets + def filesChangeCnt = 0 + for (int i = 0; i < changeLogSets.size(); i++) { + def entries = changeLogSets[i].items + for (int j = 0; j < entries.length; j++) { + def entry = entries[j] + //echo "${entry.commitId} by ${entry.author} on ${new Date(entry.timestamp)}: ${entry.msg}" + def files = new ArrayList(entry.affectedFiles) + for (int k = 0; k < files.size(); k++) { + def file = files[k] + def filePath = file.path + //echo ">> ${file.path}" + if (filePath.contains(contextDirectory)) { + filesChangeCnt = 1 + k = files.size() + j = entries.length + } + } + } + } + + if ( filesChangeCnt < 1 ) { + echo('The changes do not require a build.') + return false + } else { + echo('The changes require a build.') + return true + } +} + +// Get an image's hash tag +String getImageTagHash(String imageName, String tag = "") { + + if(!tag?.trim()) { + tag = "latest" + } + + def istag = openshift.raw("get istag ${imageName}:${tag} -o template --template='{{.image.dockerImageReference}}'") + return istag.out.tokenize('@')[1].trim() +} + +// define job properties - keep 10 builds only +properties([[$class: 'BuildDiscarderProperty', strategy: [$class: 'LogRotator', artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '10']]]) + +def run_pipeline = true + +// build wasn't triggered by changes so check with user +if( !triggerBuild(APP_NAME) ) { + stage('No changes. Run pipeline?') { + try { + timeout(time: 1, unit: 'DAYS') { + input message: "Run pipeline?", id: "1234"//, submitter: 'admin' + } + } catch (Exception e) { + run_pipeline = false; + } + } +} + +if( run_pipeline ) { + node { + def build_ok = true + def old_version + + stage("Build ${APP_NAME}") { + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_BUILD}") { + try { + echo "Building ${APP_NAME} ..." + def build = openshift.selector("bc", "${APP_NAME}").startBuild() + build.untilEach { + return it.object().status.phase == "Running" + } + build.logs('-f') + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + } + } + } + } + + if (build_ok) { + stage("Tag ${APP_NAME}:${DESTINATION_TAG}") { + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_DEPLOY}") { + old_version = openshift.selector('dc', "${APP_NAME}-${DESTINATION_TAG}").object().status.latestVersion + } + } + openshift.withCluster() { + openshift.withProject("${NAMESPACE_BUILD}") { + try { + echo "Tagging ${APP_NAME} for deployment to ${DESTINATION_TAG} ..." + + // Don't tag with BUILD_ID so the pruner can do it's job; it won't delete tagged images. + // Tag the images for deployment based on the image's hash + def IMAGE_HASH = getImageTagHash("${APP_NAME}") + echo "IMAGE_HASH: ${IMAGE_HASH}" + openshift.tag("${APP_NAME}@${IMAGE_HASH}", "${APP_NAME}:${DESTINATION_TAG}") + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + } + } + } + } + } + + if (build_ok) { + stage("Deploy ${APP_NAME}-${DESTINATION_TAG}") { + sleep 10 + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_DEPLOY}") { + try { + def new_version = openshift.selector('dc', "${APP_NAME}-${DESTINATION_TAG}").object().status.latestVersion + if (new_version == old_version) { + echo "New deployment was not triggered." + } + + def pod_selector = openshift.selector('pod', [ app:"${APP_NAME}-${DESTINATION_TAG}" ]) + pod_selector.untilEach { + deployment = it.objects()[0].metadata.labels.deployment + echo deployment + if (deployment == "${APP_NAME}-${DESTINATION_TAG}-${new_version}" && it.objects()[0].status.phase == 'Running' && it.objects()[0].status.containerStatuses[0].ready) { + return true + } else { + echo "Pod for new deployment not ready" + sleep 5 + return false + } + } + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + } + } + } + } + } + + /*if (build_ok) { + try { + stage("Run tests on ${APP_NAME}:${DESTINATION_TAG}") { + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_UNITTEST}") { + def test_pipeline = openshift.selector('bc', 'pytest-pipeline') + + test_pipeline.startBuild('--wait=true', "-e=component=${APP_NAME}", "-e=component_tag=${DESTINATION_TAG}", "-e=tag=${DESTINATION_TAG}", "-e=namespace=${NAMESPACE_APP}").logs('-f') + echo "All tests passed" + } + } + } + } + } catch (Exception e) { + echo e.getMessage() + echo "Not all tests passed." + build_ok = false + } + } + + if (build_ok) { + stage("Run E2E API tests") { + + } + }*/ + + stage("Notify on RocketChat") { + if(build_ok) { + currentBuild.result = "SUCCESS" + } else { + currentBuild.result = "FAILURE" + } + + ROCKETCHAT_TOKEN = sh ( + script: """oc get secret/apitest-secrets -n ${NAMESPACE_BUILD} -o template --template="{{.data.ROCKETCHAT_TOKEN}}" | base64 --decode""", + returnStdout: true).trim() + + rocketChatNotificaiton("${ROCKETCHAT_TOKEN}", "${ROCKETCHAT_DEVELOPER_CHANNEL}", "${APP_NAME} build and deploy to ${DESTINATION_TAG} ${currentBuild.result}!") + } + } +} + diff --git a/bcol-api/jenkins/test.groovy b/bcol-api/jenkins/test.groovy new file mode 100644 index 000000000..f34ee2826 --- /dev/null +++ b/bcol-api/jenkins/test.groovy @@ -0,0 +1,121 @@ +#!/usr/bin/env groovy +// Copyright © 2018 Province of British Columbia +// +// 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. +// +//JENKINS DEPLOY ENVIRONMENT VARIABLES: +// - JENKINS_JAVA_OVERRIDES -Dhudson.model.DirectoryBrowserSupport.CSP= -Duser.timezone=America/Vancouver +// -> user.timezone : set the local timezone so logfiles report correxct time +// -> hudson.model.DirectoryBrowserSupport.CSP : removes restrictions on CSS file load, thus html pages of test reports are displayed pretty +// See: https://docs.openshift.com/container-platform/3.9/using_images/other_images/jenkins.html for a complete list of JENKINS env vars + +import groovy.json.* + +// define constants - values sent in as env vars from whatever calls this pipeline +def APP_NAME = 'bcol-api' +def SOURCE_TAG = 'dev' +def DESTINATION_TAG = 'test' +def TOOLS_TAG = 'tools' + +def NAMESPACE_APP = 'l4ygcl' +def NAMESPACE_BUILD = "${NAMESPACE_APP}" + '-' + "${TOOLS_TAG}" +def NAMESPACE_DEPLOY = "${NAMESPACE_APP}" + '-' + "${DESTINATION_TAG}" + +def ROCKETCHAT_DEVELOPER_CHANNEL='#relationship-developers' + +// post a notification to rocketchat +def rocketChatNotificaiton(token, channel, comments) { + def payload = JsonOutput.toJson([text: comments, channel: channel]) + def rocketChatUrl = "https://chat.pathfinder.gov.bc.ca/hooks/" + "${token}" + + sh(returnStdout: true, + script: "curl -X POST -H 'Content-Type: application/json' --data \'${payload}\' ${rocketChatUrl}") +} + +node { + properties([[$class: 'BuildDiscarderProperty', strategy: [$class: 'LogRotator', artifactDaysToKeepStr: '', artifactNumToKeepStr: '', daysToKeepStr: '', numToKeepStr: '10']]]) + + def build_ok = true + def old_version + + try { + stage("Tag ${APP_NAME}:${DESTINATION_TAG}") { + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_DEPLOY}") { + old_version = openshift.selector('dc', "${APP_NAME}-${DESTINATION_TAG}").object().status.latestVersion + } + } + openshift.withCluster() { + openshift.withProject("${NAMESPACE_BUILD}") { + echo "Tagging ${APP_NAME} for deployment to ${DESTINATION_TAG} ..." + openshift.tag("${APP_NAME}:${SOURCE_TAG}", "${APP_NAME}:${DESTINATION_TAG}") + } + } + } + } + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + + + if (build_ok) { + try { + stage("Deploy ${APP_NAME}-${DESTINATION_TAG}") { + sleep 10 + script { + openshift.withCluster() { + openshift.withProject("${NAMESPACE_DEPLOY}") { + + def new_version = openshift.selector('dc', "${APP_NAME}-${DESTINATION_TAG}").object().status.latestVersion + if (new_version == old_version) { + echo "New deployment was not triggered." + } + + def pod_selector = openshift.selector('pod', [ app:"${APP_NAME}-${DESTINATION_TAG}" ]) + pod_selector.untilEach { + deployment = it.objects()[0].metadata.labels.deployment + echo deployment + if (deployment == "${APP_NAME}-${DESTINATION_TAG}-${new_version}" && it.objects()[0].status.phase == 'Running' && it.objects()[0].status.containerStatuses[0].ready) { + return true + } else { + echo "Pod for new deployment not ready" + sleep 5 + return false + } + } + } + } + } + } + } catch (Exception e) { + echo e.getMessage() + build_ok = false + } + } + + stage("Notify on RocketChat") { + if(build_ok) { + currentBuild.result = "SUCCESS" + } else { + currentBuild.result = "FAILURE" + } + + ROCKETCHAT_TOKEN = sh ( + script: """oc get secret/apitest-secrets -n ${NAMESPACE_BUILD} -o template --template="{{.data.ROCKETCHAT_TOKEN}}" | base64 --decode""", + returnStdout: true).trim() + + rocketChatNotificaiton("${ROCKETCHAT_TOKEN}", "${ROCKETCHAT_DEVELOPER_CHANNEL}", "${APP_NAME} build and deploy to ${DESTINATION_TAG} ${currentBuild.result}!") + } +} \ No newline at end of file diff --git a/bcol-api/logging.conf b/bcol-api/logging.conf new file mode 100755 index 000000000..ffc1a01e3 --- /dev/null +++ b/bcol-api/logging.conf @@ -0,0 +1,28 @@ +[loggers] +keys=root,api + +[handlers] +keys=console + +[formatters] +keys=simple + +[logger_root] +level=DEBUG +handlers=console + +[logger_api] +level=DEBUG +handlers=console +qualname=api +propagate=0 + +[handler_console] +class=StreamHandler +level=DEBUG +formatter=simple +args=(sys.stdout,) + +[formatter_simple] +format=%(asctime)s - %(name)s - %(levelname)s in %(module)s:%(filename)s:%(lineno)d - %(funcName)s: %(message)s +datefmt= \ No newline at end of file diff --git a/bcol-api/manage.py b/bcol-api/manage.py new file mode 100755 index 000000000..fd9bc8b8f --- /dev/null +++ b/bcol-api/manage.py @@ -0,0 +1,36 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Manage the database and some other items required to run the API +""" +import logging + +from flask_migrate import Migrate, MigrateCommand +from flask_script import Manager # class for handling a set of commands + +# models included so that migrate can build the database migrations +from bcol_api import models # pylint: disable=unused-import +from bcol_api import create_app +from bcol_api.models import db + + +APP = create_app() +MIGRATE = Migrate(APP, db) +MANAGER = Manager(APP) + +MANAGER.add_command('db', MigrateCommand) + +if __name__ == '__main__': + logging.log(logging.INFO, 'Running the Manager') + MANAGER.run() diff --git a/bcol-api/openshift/templates/bcol-api-build.json b/bcol-api/openshift/templates/bcol-api-build.json new file mode 100644 index 000000000..32ef77c57 --- /dev/null +++ b/bcol-api/openshift/templates/bcol-api-build.json @@ -0,0 +1,137 @@ +{ + "kind": "Template", + "apiVersion": "v1", + "metadata": { + "annotations": { + "description": "Build template for a bcol-api service.", + "tags": "flask", + "iconClass": "icon-python" + }, + "name": "${NAME}-build" + }, + "objects": [ + { + "kind": "ImageStream", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}" + } + }, + { + "kind": "BuildConfig", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}", + "labels": { + "app": "${NAME}", + "app-group": "${APP_GROUP}", + "template": "${NAME}-build" + } + }, + "spec": { + "source": { + "type": "Git", + "git": { + "uri": "${GIT_REPO_URL}", + "ref": "${GIT_REF}" + }, + "contextDir": "${SOURCE_CONTEXT_DIR}" + }, + "strategy": { + "type": "Source", + "sourceStrategy": { + "from": { + "kind": "${SOURCE_IMAGE_KIND}", + "name": "${SOURCE_IMAGE_NAME}:${SOURCE_IMAGE_TAG}", + "namespace": "${SOURCE_IMAGE_NAME_SPACE}" + }, + "env": [] + } + }, + "output": { + "to": { + "kind": "ImageStreamTag", + "name": "${NAME}:${OUTPUT_IMAGE_TAG}" + } + }, + "triggers": [ + { + "type": "ConfigChange" + } + ] + } + } + ], + "parameters": [ + { + "name": "NAME", + "displayName": "Name", + "description": "The name assigned to all of the objects defined in this template. You should keep this as default unless your know what your doing.", + "required": true, + "value": "bcol-api" + }, + { + "name": "APP_GROUP", + "displayName": "App Group", + "description": "The name assigned to all of the deployments in this project.", + "required": true, + "value": "sbc-pay" + }, + { + "name": "GIT_REPO_URL", + "displayName": "Git Repo URL", + "description": "The URL to your GIT repo, don't use the this default unless your just experimenting.", + "required": true, + "value": "https://github.com/bcgov/sbc-pay.git" + }, + { + "name": "GIT_REF", + "displayName": "Git Reference", + "description": "The git reference or branch.", + "required": true, + "value": "development" + }, + { + "name": "SOURCE_CONTEXT_DIR", + "displayName": "Source Context Directory", + "description": "The source context directory.", + "required": true, + "value": "bcol-api" + }, + { + "name": "SOURCE_IMAGE_KIND", + "displayName": "Source Image Kind", + "required": true, + "description": "The 'kind' (type) of the source image; typically ImageStreamTag, or DockerImage.", + "value": "ImageStreamTag" + }, + { + "name": "SOURCE_IMAGE_NAME_SPACE", + "displayName": "Source Image Name Space", + "required": true, + "description": "The name space of the source image.", + "value": "d7eovc-tools" + }, + { + "name": "SOURCE_IMAGE_NAME", + "displayName": "Source Image Name", + "required": true, + "description": "The name of the source image.", + "value": "python" + }, + { + "name": "SOURCE_IMAGE_TAG", + "displayName": "Source Image Tag", + "required": true, + "description": "The tag of the source image.", + "value": "3.7" + }, + { + "name": "OUTPUT_IMAGE_TAG", + "displayName": "Output Image Tag", + "description": "The tag given to the built image.", + "required": true, + "value": "latest" + } + ] +} \ No newline at end of file diff --git a/bcol-api/openshift/templates/bcol-api-deploy.e2e.json b/bcol-api/openshift/templates/bcol-api-deploy.e2e.json new file mode 100644 index 000000000..e1d43c9d9 --- /dev/null +++ b/bcol-api/openshift/templates/bcol-api-deploy.e2e.json @@ -0,0 +1,306 @@ +{ + "kind": "Template", + "apiVersion": "v1", + "metadata": { + "annotations": { + "description": "Deployment template for a bcol api service.", + "tags": "${NAME}-${TAG_NAME}" + }, + "name": "${NAME}-${TAG_NAME}-deploy" + }, + "objects": [ + { + "kind": "DeploymentConfig", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}-${TAG_NAME}", + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "template": "${NAME}-deploy" + } + }, + "spec": { + "strategy": { + "type": "Rolling", + "rollingParams": { + "updatePeriodSeconds": 1, + "intervalSeconds": 1, + "timeoutSeconds": 600, + "maxUnavailable": "25%", + "maxSurge": "25%" + } + }, + "triggers": [ + { + "type": "ImageChange", + "imageChangeParams": { + "automatic": true, + "containerNames": [ + "${NAME}-${TAG_NAME}" + ], + "from": { + "kind": "ImageStreamTag", + "namespace": "${IMAGE_NAMESPACE}", + "name": "${NAME}:${TAG_NAME}" + } + } + }, + { + "type": "ConfigChange" + } + ], + "replicas": "${REPLICAS}", + "test": false, + "selector": { + "app": "${NAME}-${TAG_NAME}", + "deploymentconfig": "${NAME}-${TAG_NAME}" + }, + "template": { + "metadata": { + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "deploymentconfig": "${NAME}-${TAG_NAME}", + "template": "${NAME}-deploy" + } + }, + "spec": { + "containers": [ + { + "name": "${NAME}-${TAG_NAME}", + "image": "docker-registry.default.svc:5000/${IMAGE_NAMESPACE}/${NAME}:${TAG_NAME}", + "ports": [ + { + "containerPort": 8080, + "protocol": "TCP" + } + ], + "envFrom": [ + { + "configMapRef": { + "name": "api-${TAG_NAME}-config" + } + } + ], + "env": [ + + ], + "resources": { + "requests": { + "cpu": "${CPU_REQUEST}", + "memory": "${MEMORY_REQUEST}" + }, + "limits": { + "cpu": "${CPU_LIMIT}", + "memory": "${MEMORY_LIMIT}" + } + }, + "livenessProbe": { + "httpGet": { + "path": "/ops/healthz", + "port": 8080, + "scheme": "HTTP" + }, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "readinessProbe": { + "httpGet": { + "path": "/ops/readyz", + "port": 8080, + "scheme": "HTTP" + }, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "Always" + }, + { + "name": "jaeger-agent", + "image": "jaegertracing/jaeger-agent", + "ports": [ + { + "containerPort": 5775, + "protocol": "UDP" + }, + { + "containerPort": 5778, + "protocol": "UDP" + }, + { + "containerPort": 6831, + "protocol": "UDP" + }, + { + "containerPort": 6832, + "protocol": "UDP" + } + ], + "args": [ + "${JAEGER_COLLECTOR}" + ] + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + } + }, + { + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}-${TAG_NAME}", + "creationTimestamp": null, + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "template": "${NAME}-deploy" + } + }, + "spec": { + "ports": [ + { + "name": "8080-tcp", + "protocol": "TCP", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "deploymentconfig": "${NAME}-${TAG_NAME}" + }, + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } + }, + { + "kind": "Route", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}-${TAG_NAME}", + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "template": "${NAME}-deploy" + } + }, + "spec": { + "host": "${NAME}-${TAG_NAME}.pathfinder.gov.bc.ca", + "to": { + "kind": "Service", + "name": "${NAME}-${TAG_NAME}", + "weight": 100 + }, + "port": { + "targetPort": "8080-tcp" + }, + "tls": { + "termination": "edge" + }, + "wildcardPolicy": "None" + }, + "status": { + "ingress": [ + { + "host": "${NAME}-${TAG_NAME}.pathfinder.gov.bc.ca", + "routerName": "router", + "conditions": [ + { + "type": "Admitted", + "status": "True" + } + ], + "wildcardPolicy": "None" + } + ] + } + } + ], + "parameters": [ + { + "name": "NAME", + "displayName": "Name", + "description": "The name assigned to all of the OpenShift resources associated to the server instance.", + "required": true, + "value": "bcol-api" + }, + { + "name": "APP_GROUP", + "displayName": "App Group", + "description": "The name assigned to all of the deployments in this project.", + "required": true, + "value": "sbc-pay" + }, + { + "name": "IMAGE_NAMESPACE", + "displayName": "Image Namespace", + "required": true, + "description": "The namespace of the OpenShift project containing the imagestream for the application.", + "value": "l4ygcl-tools" + }, + { + "name": "TAG_NAME", + "displayName": "Environment TAG name", + "description": "The TAG name for this environment, e.g., dev, test, prod", + "required": true, + "value": "dev" + }, + { + "name": "CPU_REQUEST", + "displayName": "Resources CPU Request", + "description": "The resources CPU request (in cores) for this build.", + "required": true, + "value": "100m" + }, + { + "name": "CPU_LIMIT", + "displayName": "Resources CPU Limit", + "description": "The resources CPU limit (in cores) for this build.", + "required": true, + "value": "750m" + }, + { + "name": "MEMORY_REQUEST", + "displayName": "Resources Memory Request", + "description": "The resources Memory request (in Mi, Gi, etc) for this build.", + "required": true, + "value": "100Mi" + }, + { + "name": "MEMORY_LIMIT", + "displayName": "Resources Memory Limit", + "description": "The resources Memory limit (in Mi, Gi, etc) for this build.", + "required": true, + "value": "2Gi" + }, + { + "name": "REPLICAS", + "displayName": "The number of replicas to run", + "description": "The number of replicas to run in this environment.", + "required": true, + "value": "1" + }, + { + "name": "JAEGER_COLLECTOR", + "displayName": "Jaeger Tracing collector address", + "description": "Jaeger Tracing collector address.", + "required": true, + "value": "--collector.host-port=jaeger-collector.d7eovc-${TAG_NAME}.svc:14267" + } + ] +} \ No newline at end of file diff --git a/bcol-api/openshift/templates/bcol-api-deploy.json b/bcol-api/openshift/templates/bcol-api-deploy.json new file mode 100644 index 000000000..2d02d5c97 --- /dev/null +++ b/bcol-api/openshift/templates/bcol-api-deploy.json @@ -0,0 +1,306 @@ +{ + "kind": "Template", + "apiVersion": "v1", + "metadata": { + "annotations": { + "description": "Deployment template for a bcol api service.", + "tags": "${NAME}-${TAG_NAME}" + }, + "name": "${NAME}-${TAG_NAME}-deploy" + }, + "objects": [ + { + "kind": "DeploymentConfig", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}-${TAG_NAME}", + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "template": "${NAME}-deploy" + } + }, + "spec": { + "strategy": { + "type": "Rolling", + "rollingParams": { + "updatePeriodSeconds": 1, + "intervalSeconds": 1, + "timeoutSeconds": 600, + "maxUnavailable": "25%", + "maxSurge": "25%" + } + }, + "triggers": [ + { + "type": "ImageChange", + "imageChangeParams": { + "automatic": true, + "containerNames": [ + "${NAME}-${TAG_NAME}" + ], + "from": { + "kind": "ImageStreamTag", + "namespace": "${IMAGE_NAMESPACE}", + "name": "${NAME}:${TAG_NAME}" + } + } + }, + { + "type": "ConfigChange" + } + ], + "replicas": "${REPLICAS}", + "test": false, + "selector": { + "app": "${NAME}-${TAG_NAME}", + "deploymentconfig": "${NAME}-${TAG_NAME}" + }, + "template": { + "metadata": { + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "deploymentconfig": "${NAME}-${TAG_NAME}", + "template": "${NAME}-deploy" + } + }, + "spec": { + "containers": [ + { + "name": "${NAME}-${TAG_NAME}", + "image": "docker-registry.default.svc:5000/${IMAGE_NAMESPACE}/${NAME}:${TAG_NAME}", + "ports": [ + { + "containerPort": 8080, + "protocol": "TCP" + } + ], + "envFrom": [ + { + "configMapRef": { + "name": "api-${TAG_NAME}-config" + } + } + ], + "env": [ + + ], + "resources": { + "requests": { + "cpu": "${CPU_REQUEST}", + "memory": "${MEMORY_REQUEST}" + }, + "limits": { + "cpu": "${CPU_LIMIT}", + "memory": "${MEMORY_LIMIT}" + } + }, + "livenessProbe": { + "httpGet": { + "path": "/ops/healthz", + "port": 8080, + "scheme": "HTTP" + }, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "readinessProbe": { + "httpGet": { + "path": "/ops/readyz", + "port": 8080, + "scheme": "HTTP" + }, + "timeoutSeconds": 1, + "periodSeconds": 10, + "successThreshold": 1, + "failureThreshold": 3 + }, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "Always" + }, + { + "name": "jaeger-agent", + "image": "jaegertracing/jaeger-agent", + "ports": [ + { + "containerPort": 5775, + "protocol": "UDP" + }, + { + "containerPort": 5778, + "protocol": "UDP" + }, + { + "containerPort": 6831, + "protocol": "UDP" + }, + { + "containerPort": 6832, + "protocol": "UDP" + } + ], + "args": [ + "${JAEGER_COLLECTOR}" + ] + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + } + } + }, + { + "kind": "Service", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}-${TAG_NAME}", + "creationTimestamp": null, + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "template": "${NAME}-deploy" + } + }, + "spec": { + "ports": [ + { + "name": "8080-tcp", + "protocol": "TCP", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "deploymentconfig": "${NAME}-${TAG_NAME}" + }, + "type": "ClusterIP", + "sessionAffinity": "None" + }, + "status": { + "loadBalancer": {} + } + }, + { + "kind": "Route", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}-${TAG_NAME}", + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "template": "${NAME}-deploy" + } + }, + "spec": { + "host": "${NAME}-${TAG_NAME}.pathfinder.gov.bc.ca", + "to": { + "kind": "Service", + "name": "${NAME}-${TAG_NAME}", + "weight": 100 + }, + "port": { + "targetPort": "8080-tcp" + }, + "tls": { + "termination": "edge" + }, + "wildcardPolicy": "None" + }, + "status": { + "ingress": [ + { + "host": "${NAME}-${TAG_NAME}.pathfinder.gov.bc.ca", + "routerName": "router", + "conditions": [ + { + "type": "Admitted", + "status": "True" + } + ], + "wildcardPolicy": "None" + } + ] + } + } + ], + "parameters": [ + { + "name": "NAME", + "displayName": "Name", + "description": "The name assigned to all of the OpenShift resources associated to the server instance.", + "required": true, + "value": "bcol-api" + }, + { + "name": "APP_GROUP", + "displayName": "App Group", + "description": "The name assigned to all of the deployments in this project.", + "required": true, + "value": "sbc-pay" + }, + { + "name": "IMAGE_NAMESPACE", + "displayName": "Image Namespace", + "required": true, + "description": "The namespace of the OpenShift project containing the imagestream for the application.", + "value": "l4ygcl-tools" + }, + { + "name": "TAG_NAME", + "displayName": "Environment TAG name", + "description": "The TAG name for this environment, e.g., dev, test, prod", + "required": true, + "value": "dev" + }, + { + "name": "CPU_REQUEST", + "displayName": "Resources CPU Request", + "description": "The resources CPU request (in cores) for this build.", + "required": true, + "value": "100m" + }, + { + "name": "CPU_LIMIT", + "displayName": "Resources CPU Limit", + "description": "The resources CPU limit (in cores) for this build.", + "required": true, + "value": "750m" + }, + { + "name": "MEMORY_REQUEST", + "displayName": "Resources Memory Request", + "description": "The resources Memory request (in Mi, Gi, etc) for this build.", + "required": true, + "value": "100Mi" + }, + { + "name": "MEMORY_LIMIT", + "displayName": "Resources Memory Limit", + "description": "The resources Memory limit (in Mi, Gi, etc) for this build.", + "required": true, + "value": "2Gi" + }, + { + "name": "REPLICAS", + "displayName": "The number of replicas to run", + "description": "The number of replicas to run in this environment.", + "required": true, + "value": "1" + }, + { + "name": "JAEGER_COLLECTOR", + "displayName": "Jaeger Tracing collector address", + "description": "Jaeger Tracing collector address.", + "required": true, + "value": "--collector.host-port=jaeger-collector.d7eovc-${TAG_NAME}.svc:14267" + } + ] +} \ No newline at end of file diff --git a/bcol-api/openshift/templates/bcol-api-pipeline.json b/bcol-api/openshift/templates/bcol-api-pipeline.json new file mode 100644 index 000000000..b71df9e5d --- /dev/null +++ b/bcol-api/openshift/templates/bcol-api-pipeline.json @@ -0,0 +1,113 @@ +{ + "kind": "Template", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}-pipeline" + }, + "objects": [ + { + "kind": "BuildConfig", + "apiVersion": "v1", + "metadata": { + "name": "${NAME}-${TAG_NAME}-pipeline", + "creationTimestamp": null, + "labels": { + "app": "${NAME}-${TAG_NAME}", + "app-group": "${APP_GROUP}", + "template": "${NAME}-pipeline" + } + }, + "spec": { + "triggers": [ + { + "type": "GitHub", + "github": { + "secretReference": { + "name": "${WEBHOOK}" + } + } + } + ], + "runPolicy": "Serial", + "source": { + "type": "Git", + "git": { + "uri": "${GIT_REPO_URL}", + "ref": "${GIT_REF}" + }, + "contextDir": "${SOURCE_CONTEXT_DIR}" + }, + "strategy": { + "type": "JenkinsPipeline", + "jenkinsPipelineStrategy": { + "jenkinsfilePath": "${JENKINS_FILE}" + } + }, + "output": {}, + "resources": {}, + "postCommit": {}, + "nodeSelector": {}, + "successfulBuildsHistoryLimit": 5, + "failedBuildsHistoryLimit": 5 + } + } + ], + "parameters": [ + { + "name": "NAME", + "displayName": "Name", + "description": "The name assigned to all of the resources defined in this template.", + "required": true, + "value": "bcol-api" + }, + { + "name": "APP_GROUP", + "displayName": "App Group", + "description": "The name assigned to all of the deployments in this project.", + "required": true, + "value": "sbc-pay" + }, + { + "name": "TAG_NAME", + "displayName": "Pipeline purpose", + "description": "The activity that this pipeline will manage. eg. build, test, promote, etc.", + "required": true, + "value": "dev" + }, + { + "name": "GIT_REPO_URL", + "displayName": "Git Repo URL", + "description": "The URL to your GIT repo.", + "required": true, + "value": "https://github.com/bcgov/sbc-pay.git" + }, + { + "name": "GIT_REF", + "displayName": "Git Reference", + "description": "The git reference or branch.", + "required": true, + "value": "development" + }, + { + "name": "WEBHOOK", + "displayName": "Secret name for the webhook.", + "description": "The name of the Secret that holds the webhook.", + "required": true, + "value": "github-${NAME}-${TAG_NAME}" + }, + { + "name": "SOURCE_CONTEXT_DIR", + "displayName": "Source Context Directory", + "description": "The source context directory.", + "required": false, + "value": "bcol-api" + }, + { + "name": "JENKINS_FILE", + "displayName": "The Jenksinfile this pipeline should use.", + "description": "The Jenkinsfile this pipeline should use.", + "required": false, + "value": "./jenkins/${TAG_NAME}.groovy" + } + ] +} diff --git a/bcol-api/requirements.txt b/bcol-api/requirements.txt new file mode 100755 index 000000000..2c27f7e40 --- /dev/null +++ b/bcol-api/requirements.txt @@ -0,0 +1,50 @@ +Click==7.0 +Flask-Moment==0.9.0 +Flask-SQLAlchemy==2.4.1 +Flask-Script==2.0.6 +Flask==1.1.1 +Jinja2==2.10.3 +MarkupSafe==1.1.1 +SQLAlchemy==1.3.10 +Werkzeug==0.16.0 +aniso8601==8.0.0 +appdirs==1.4.3 +attrs==19.1.0 +blinker==1.4 +cached-property==1.5.1 +certifi==2019.9.11 +chardet==3.0.4 +defusedxml==0.6.0 +ecdsa==0.13.3 +flask-jwt-oidc==0.1.5 +flask-marshmallow==0.10.1 +flask-restplus==0.13.0 +future==0.18.1 +gunicorn==19.9.0 +idna==2.8 +importlib-metadata==0.23 +isodate==0.6.0 +itsdangerous==1.1.0 +jsonschema==3.1.1 +lxml==4.4.1 +marshmallow-sqlalchemy==0.19.0 +marshmallow==3.0.0rc7 +more-itertools==7.2.0 +psycopg2-binary==2.8.4 +pyasn1-modules==0.2.7 +pyasn1==0.4.7 +pyrsistent==0.15.4 +python-dotenv==0.10.3 +python-jose==3.0.1 +python-ldap==3.2.0 +pytz==2019.3 +requests-toolbelt==0.9.1 +requests==2.22.0 +rsa==4.0 +sentry-sdk==0.13.0 +six==1.12.0 +urllib3==1.25.6 +zeep==3.4.0 +zipp==0.6.0 +-e git://github.com/pwei1018/jaeger-client-python.git@186f14e14758273ed108508c0d388a4f4de5c75b#egg=jaeger-client +-e git+https://github.com/bcgov/sbc-common-components.git#egg=sbc-common-components-1.0.0&subdirectory=python diff --git a/bcol-api/requirements/dev.txt b/bcol-api/requirements/dev.txt new file mode 100755 index 000000000..172f552e7 --- /dev/null +++ b/bcol-api/requirements/dev.txt @@ -0,0 +1,23 @@ +# Everything the developer needs in addition to the production requirements +-r prod.txt + +# Testing +pytest<4.1 +pytest-mock +requests +pyhamcrest +pytest-cov + +# Lint and code style +flake8 +flake8-blind-except +flake8-debugger +flake8-docstrings +flake8-isort +flake8-quotes +pep8-naming +autopep8 +coverage +pylint +pylint-flask +pydocstyle<4 diff --git a/bcol-api/requirements/prod.txt b/bcol-api/requirements/prod.txt new file mode 100755 index 000000000..28c7e38e1 --- /dev/null +++ b/bcol-api/requirements/prod.txt @@ -0,0 +1,18 @@ +gunicorn +Flask +Flask-Script +Flask-Moment +Flask-SQLAlchemy +Flask-RESTplus +Flask-Marshmallow<3 +flask-jwt-oidc>=0.1.5 +python-dotenv +psycopg2-binary +marshmallow==3.0.0.rc7 +marshmallow-sqlalchemy +jsonschema +requests +zeep +python-ldap +sentry-sdk[flask] +attrs<=19.1.0 \ No newline at end of file diff --git a/bcol-api/requirements/repo-libraries.txt b/bcol-api/requirements/repo-libraries.txt new file mode 100644 index 000000000..7950baf08 --- /dev/null +++ b/bcol-api/requirements/repo-libraries.txt @@ -0,0 +1,2 @@ +-e git://github.com/pwei1018/jaeger-client-python.git@186f14e14758273ed108508c0d388a4f4de5c75b#egg=jaeger-client +-e git+https://github.com/bcgov/sbc-common-components.git#egg=sbc-common-components-1.0.0&subdirectory=python diff --git a/bcol-api/scripts/verify_license_headers.sh b/bcol-api/scripts/verify_license_headers.sh new file mode 100755 index 000000000..028b95c63 --- /dev/null +++ b/bcol-api/scripts/verify_license_headers.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# Copyright © 2019 Province of British Columbia +# +# 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. + + +COPYRIGHT="Copyright © 2019 Province of British Columbia" +RET=0 + +for file in $(find $@ -not \( -path */venv -prune \) -not \( -path */migrations -prune \) -not \( -path */tests -prune \) -not \( -path */.egg* -prune \) -name \*.py) +do + grep "${COPYRIGHT}" ${file} >/dev/null + if [[ $? != 0 ]] + then + echo "${file} missing copyright header" + RET=1 + fi +done +exit ${RET} diff --git a/bcol-api/setup.cfg b/bcol-api/setup.cfg new file mode 100755 index 000000000..7b1c20722 --- /dev/null +++ b/bcol-api/setup.cfg @@ -0,0 +1,116 @@ +[metadata] +name = bcol_api +url = https://github.com/bcgov/sbc-pay/ +author = Relationships Team +author_email = +classifiers = + Development Status :: Beta + Intended Audience :: Developers / QA + Topic :: Payment + License :: OSI Approved :: Apache Software License + Natural Language :: English + Programming Language :: Python :: 3.7 +license = Apache Software License Version 2.0 +description = A short description of the project +long_description = file: README.md +keywords = + +[options] +zip_safe = True +python_requires = >=3.6 +include_package_data = True +packages = find: + +[options.package_data] +bcol_api = + +[wheel] +universal = 1 + +[bdist_wheel] +universal = 1 + +[aliases] +test = pytest + +[flake8] +exclude = .git,*migrations* +max-line-length = 120 +docstring-min-length=10 +per-file-ignores = + */__init__.py:F401 + +[pycodestyle] +max_line_length = 120 +ignore = E501 +docstring-min-length=10 +notes=FIXME,XXX # TODO is ignored +match_dir = src/bcol_api +ignored-modules=flask_sqlalchemy + sqlalchemy +per-file-ignores = + */__init__.py:F401 +good-names= + b, + d, + i, + e, + f, + u, + rv, + logger, + id, + p, + +[pylint] +ignore=migrations,test +max_line_length=120 +notes=FIXME,XXX,TODO +ignored-modules=flask_sqlalchemy,sqlalchemy,SQLAlchemy,alembic,scoped_session +ignored-classes=scoped_session +min-similarity-lines=8 + +[isort] +line_length = 120 +indent = 4 +multi_line_output = 4 +lines_after_imports = 2 + +[tool:pytest] +minversion = 2.0 +testpaths = tests +addopts = --verbosity=0 + --strict + -p no:warnings + --cov=src/bcol_api --cov-report xml --cov-report term + --junitxml=pytest.xml +python_files = tests/*/test*.py +norecursedirs = .git .tox venv* requirements* build +log_cli = true +log_cli_level = 1 +filterwarnings = + ignore::UserWarning +markers = + slow + serial + +[coverage:run] +branch = True +source = + src/bcol_api +omit = + src/bcol_api/wsgi.py + src/bcol_api/gunicorn_config.py + +[report:run] +exclude_lines = + pragma: no cover + from + import + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: diff --git a/bcol-api/setup.py b/bcol-api/setup.py new file mode 100755 index 000000000..f15130d12 --- /dev/null +++ b/bcol-api/setup.py @@ -0,0 +1,61 @@ +# Copyright © 2019 Province of British Columbia. +# +# 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. +"""Installer and setup for this module +""" +from glob import glob +from os.path import basename, splitext + +from setuptools import find_packages, setup + + +def read_requirements(filename): + """ + Get application requirements from + the requirements.txt file. + :return: Python requirements + :rtype: list + """ + with open(filename, 'r') as req: + requirements = req.readlines() + install_requires = [r.strip() for r in requirements if r.find('git+') != 0] + return install_requires + + +def read(filepath): + """ + Read the contents from a file. + :param str filepath: path to the file to be read + :return: file contents + :rtype: str + """ + with open(filepath, 'r') as file_handle: + content = file_handle.read() + return content + + +REQUIREMENTS = read_requirements('requirements/prod.txt') + +setup( + name="bcol_api", + packages=find_packages('src'), + package_dir={'': 'src'}, + py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], + include_package_data=True, + license=read('LICENSE'), + long_description=read('README.md'), + zip_safe=False, + install_requires=REQUIREMENTS, + setup_requires=["pytest-runner"], + tests_require=["pytest"], +) diff --git a/bcol-api/src/bcol_api/__init__.py b/bcol-api/src/bcol_api/__init__.py new file mode 100755 index 000000000..6bf887292 --- /dev/null +++ b/bcol-api/src/bcol_api/__init__.py @@ -0,0 +1,84 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""The Payment API service. + +This module is the API for the Legal Entity system. +""" + +import os + +import sentry_sdk # noqa: I001; pylint: disable=ungrouped-imports; conflicts with Flake8 +from flask import Flask +from sbc_common_components.exception_handling.exception_handler import ExceptionHandler +from sbc_common_components.utils.camel_case_response import convert_to_camel +from sentry_sdk.integrations.flask import FlaskIntegration # noqa: I001 + +import config +from bcol_api.resources import API_BLUEPRINT, OPS_BLUEPRINT +from bcol_api.utils.auth import jwt +from bcol_api.utils.logging import setup_logging +from bcol_api.utils.run_version import get_run_version +from config import _Config + +setup_logging(os.path.join(_Config.PROJECT_ROOT, 'logging.conf')) # important to do this first + + +def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): + """Return a configured Flask App using the Factory method.""" + app = Flask(__name__) + app.config.from_object(config.CONFIGURATION[run_mode]) + + # Configure Sentry + if app.config.get('SENTRY_DSN', None): + sentry_sdk.init( + dsn=app.config.get('SENTRY_DSN'), + integrations=[FlaskIntegration()] + ) + + app.register_blueprint(API_BLUEPRINT) + app.register_blueprint(OPS_BLUEPRINT) + app.after_request(convert_to_camel) + + setup_jwt_manager(app, jwt) + + ExceptionHandler(app) + + @app.after_request + def add_version(response): # pylint: disable=unused-variable + version = get_run_version() + response.headers['API'] = f'bcol_api/{version}' + return response + + register_shellcontext(app) + + return app + + +def setup_jwt_manager(app, jwt_manager): + """Use flask app to configure the JWTManager to work for a particular Realm.""" + def get_roles(a_dict): + return a_dict['realm_access']['roles'] # pragma: no cover + + app.config['JWT_ROLE_CALLBACK'] = get_roles + + jwt_manager.init_app(app) + + +def register_shellcontext(app): + """Register shell context objects.""" + def shell_context(): + """Shell context objects.""" + return {'app': app, 'jwt': jwt} # pragma: no cover + + app.shell_context_processor(shell_context) diff --git a/bcol-api/src/bcol_api/exceptions/__init__.py b/bcol-api/src/bcol_api/exceptions/__init__.py new file mode 100755 index 000000000..775d98018 --- /dev/null +++ b/bcol-api/src/bcol_api/exceptions/__init__.py @@ -0,0 +1,34 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Application Specific Exceptions, to manage the business errors. + +@log_error - a decorator to automatically log the exception to the logger provided + +BusinessException - error, status_code - Business rules error +error - a description of the error {code / description: classname / full text} +status_code - where possible use HTTP Error Codes +""" + +from bcol_api.utils.errors import Error + + +class BusinessException(Exception): + """Exception that adds error code and error name, that can be used for i18n support.""" + + def __init__(self, error: Error, *args, **kwargs): + """Return a valid BusinessException.""" + super(BusinessException, self).__init__(*args, **kwargs) + self.message = error.message + self.code = error.name + self.status = error.status diff --git a/bcol-api/src/bcol_api/resources/__init__.py b/bcol-api/src/bcol_api/resources/__init__.py new file mode 100755 index 000000000..defa5238e --- /dev/null +++ b/bcol-api/src/bcol_api/resources/__init__.py @@ -0,0 +1,66 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Exposes all of the resource endpoints mounted in Flask-Blueprint style. + +Uses restplus namespaces to mount individual api endpoints into the service. + +All services have 2 defaults sets of endpoints: + - ops + - meta +That are used to expose operational health information about the service, and meta information. +""" +from flask import Blueprint +from sbc_common_components.exception_handling.exception_handler import ExceptionHandler + +from .apihelper import Api +from .bcol_profile import API as BCOL_API +from .meta import API as META_API +from .ops import API as OPS_API + + +__all__ = ('API_BLUEPRINT', 'OPS_BLUEPRINT') + +# This will add the Authorize button to the swagger docs +# TODO oauth2 & openid may not yet be supported by restplus <- check on this +AUTHORIZATIONS = {'apikey': {'type': 'apiKey', 'in': 'header', 'name': 'Authorization'}} + +OPS_BLUEPRINT = Blueprint('API_OPS', __name__, url_prefix='/ops') + +API_OPS = Api( + OPS_BLUEPRINT, + title='Service OPS API', + version='1.0', + description='Microservice for BC Online SOAP Services', + security=['apikey'], + authorizations=AUTHORIZATIONS, +) + +API_OPS.add_namespace(OPS_API, path='/') + +API_BLUEPRINT = Blueprint('API', __name__, url_prefix='/api/v1') + +API = Api( + API_BLUEPRINT, + title='BCOL API', + version='1.0', + description='Microservice for BC Online SOAP Services', + security=['apikey'], + authorizations=AUTHORIZATIONS, +) + +HANDLER = ExceptionHandler(API) + +API.add_namespace(META_API, path='/meta') + +API.add_namespace(BCOL_API, path='/accounts') diff --git a/bcol-api/src/bcol_api/resources/apihelper.py b/bcol-api/src/bcol_api/resources/apihelper.py new file mode 100644 index 000000000..a4f0e63a0 --- /dev/null +++ b/bcol-api/src/bcol_api/resources/apihelper.py @@ -0,0 +1,29 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Meta information about the service. + +to support swagger on http +""" +from flask import url_for +from flask_restplus import Api as BaseApi + + +class Api(BaseApi): # pragma: no cover + """Monkey patch Swagger API to return HTTPS URLs.""" + + @property + def specs_url(self): + """Return URL for endpoint.""" + scheme = 'http' if '5000' in self.base_url else 'https' + return url_for(self.endpoint('specs'), _external=True, _scheme=scheme) diff --git a/bcol-api/src/bcol_api/resources/bcol_profile.py b/bcol-api/src/bcol_api/resources/bcol_profile.py new file mode 100755 index 000000000..c6ea85b9a --- /dev/null +++ b/bcol-api/src/bcol_api/resources/bcol_profile.py @@ -0,0 +1,54 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Resource for Invoice related endpoints.""" + +from http import HTTPStatus + +from flask import request +from flask_restplus import Namespace, Resource + +from bcol_api.exceptions import BusinessException +from bcol_api.schemas import utils as schema_utils +from bcol_api.services.bcol_service import BcolService +from bcol_api.utils.auth import jwt as _jwt +from bcol_api.utils.trace import tracing as _tracing +from bcol_api.utils.util import cors_preflight + + +API = Namespace('bcol profile', description='Payment System - BCOL Profiles') + + +@cors_preflight(['POST', 'OPTIONS']) +@API.route('', methods=['POST', 'OPTIONS']) +class BcolProfile(Resource): + """Endpoint resource to manage BCOL Accounts.""" + + @staticmethod + @API.response(200, 'OK') + @_tracing.trace() + @_jwt.requires_auth + def post(): + """Return the account details.""" + try: + req_json = request.get_json() + # Validate the input request + valid_format = schema_utils.validate(req_json, 'accounts_request') + if not valid_format[0]: + response, status = {'code': 'BCOL999', 'message': 'Invalid Request'}, HTTPStatus.BAD_REQUEST + else: + response, status = BcolService().query_profile(req_json.get('userId'), + req_json.get('password')), HTTPStatus.OK + except BusinessException as exception: + response, status = {'code': exception.code, 'message': exception.message}, exception.status + return response, status diff --git a/bcol-api/src/bcol_api/resources/meta.py b/bcol-api/src/bcol_api/resources/meta.py new file mode 100755 index 000000000..da254fa78 --- /dev/null +++ b/bcol-api/src/bcol_api/resources/meta.py @@ -0,0 +1,35 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Meta information about the service. + +Currently this only provides API versioning information +""" +from flask import jsonify +from flask_restplus import Namespace, Resource + +from bcol_api.utils.run_version import get_run_version + + +API = Namespace('Meta', description='Metadata') + + +@API.route('/info') +class Info(Resource): + """Meta information about the overall service.""" + + @staticmethod + def get(): + """Return a JSON object with meta information about the Service.""" + version = get_run_version() + return jsonify(API=f'bcol_api/{version}') diff --git a/bcol-api/src/bcol_api/resources/ops.py b/bcol-api/src/bcol_api/resources/ops.py new file mode 100755 index 000000000..449c44223 --- /dev/null +++ b/bcol-api/src/bcol_api/resources/ops.py @@ -0,0 +1,43 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Endpoints to check and manage the health of the service.""" +from flask_restplus import Namespace, Resource + + +API = Namespace('OPS', description='Service - OPS checks') + + +@API.route('healthz') +class Healthz(Resource): + """Determines if the service and required dependencies are still working. + + This could be thought of as a heartbeat for the service. + """ + + @staticmethod + def get(): + """Return a JSON object stating the health of the Service and dependencies.""" + # made it here, so all checks passed + return {'message': 'api is healthy'}, 200 + + +@API.route('readyz') +class Readyz(Resource): + """Determines if the service is ready to respond.""" + + @staticmethod + def get(): + """Return a JSON object that identifies if the service is setupAnd ready to work.""" + # TODO: add a poll to the DB when called + return {'message': 'api is ready'}, 200 diff --git a/bcol-api/src/bcol_api/schemas/__init__.py b/bcol-api/src/bcol_api/schemas/__init__.py new file mode 100644 index 000000000..3fd260d45 --- /dev/null +++ b/bcol-api/src/bcol_api/schemas/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Schema package.""" diff --git a/bcol-api/src/bcol_api/schemas/schemas/accounts_request.json b/bcol-api/src/bcol_api/schemas/schemas/accounts_request.json new file mode 100644 index 000000000..e3505fea8 --- /dev/null +++ b/bcol-api/src/bcol_api/schemas/schemas/accounts_request.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://bcrs.gov.bc.ca/.well_known/schemas/accounts_request", + "type": "object", + "title": "Accounts Request", + "required": [ + "userId", + "password" + ], + "properties": { + "userId": { + "$id": "#/properties/transactions/items/properties/userId", + "type": "string", + "title": "BCOL User ID", + "default": "" + }, + "password": { + "$id": "#/properties/transactions/items/properties/password", + "type": "string", + "title": "BCOL Password" + } + } + } \ No newline at end of file diff --git a/bcol-api/src/bcol_api/schemas/utils.py b/bcol-api/src/bcol_api/schemas/utils.py new file mode 100644 index 000000000..60292c7de --- /dev/null +++ b/bcol-api/src/bcol_api/schemas/utils.py @@ -0,0 +1,93 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Utilities to load and validate against JSONSchemas. + +Test helper functions to load and assert that a JSON payload validates against a defined schema. +""" +import json +from os import listdir, path +from typing import Tuple + +from jsonschema import Draft7Validator, RefResolver, SchemaError, draft7_format_checker + + +BASE_URI = 'https://bcrs.gov.bc.ca/.well_known/schemas' + + +def get_schema_store(validate_schema: bool = False, schema_search_path: str = None) -> dict: + """Return a schema_store as a dict. + + The default returns schema_store of the default schemas found in this package. + """ + try: + if not schema_search_path: + schema_search_path = path.join(path.dirname(__file__), 'schemas') + schemastore = {} + fnames = listdir(schema_search_path) + for fname in fnames: + fpath = path.join(schema_search_path, fname) + if fpath[-5:] == '.json': + with open(fpath, 'r') as schema_fd: + schema = json.load(schema_fd) + if '$id' in schema: + schemastore[schema['$id']] = schema + + if validate_schema: + for _, schema in schemastore.items(): + Draft7Validator.check_schema(schema) + + return schemastore + except (SchemaError, json.JSONDecodeError) as error: + # handle schema error + raise error + + +def validate(json_data: json, + schema_id: str, + schema_store: dict = None, + validate_schema: bool = False, + schema_search_path: str = None + ) -> Tuple[bool, iter]: + """Load the json file and validate against loaded schema.""" + try: + if not schema_search_path: + schema_search_path = path.join(path.dirname(__file__), 'schemas') + + if not schema_store: + schema_store = get_schema_store(validate_schema, schema_search_path) + + schema = schema_store.get(f'{BASE_URI}/{schema_id}') + if validate_schema: + Draft7Validator.check_schema(schema) + + schema_file_path = path.join(schema_search_path, schema_id) + resolver = RefResolver(f'file://{schema_file_path}.json', schema, schema_store) + + if Draft7Validator(schema, + format_checker=draft7_format_checker, + resolver=resolver + ) \ + .is_valid(json_data): + return True, None + + errors = Draft7Validator(schema, + format_checker=draft7_format_checker, + resolver=resolver + ) \ + .iter_errors(json_data) + return False, errors + + except SchemaError as error: + # handle schema error + return False, error diff --git a/bcol-api/src/bcol_api/services/__init__.py b/bcol-api/src/bcol_api/services/__init__.py new file mode 100755 index 000000000..73f1345ab --- /dev/null +++ b/bcol-api/src/bcol_api/services/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Exposes all of the Services used in the API.""" diff --git a/bcol-api/src/bcol_api/services/bcol_service.py b/bcol-api/src/bcol_api/services/bcol_service.py new file mode 100644 index 000000000..2ed9accc5 --- /dev/null +++ b/bcol-api/src/bcol_api/services/bcol_service.py @@ -0,0 +1,131 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Service to manage PayBC interaction.""" + +from typing import Dict + +import ldap +import zeep +from flask import current_app + +from bcol_api.exceptions import BusinessException +from bcol_api.services.bcol_soap import BcolSoap +from bcol_api.utils.constants import account_type_mapping, auth_code_mapping, tax_status_mapping +from bcol_api.utils.errors import Error + + +class BcolService: # pylint:disable=too-few-public-methods + """Service to manage BCOL integration.""" + + def query_profile(self, bcol_user_id: str, password: str): + """Query for profile and return the results.""" + current_app.logger.debug('query_profile') + return response + + def __authenticate_user(self, user_id: str, password: str) -> bool: # pylint: disable=no-self-use + """Validate the user by ldap lookup.""" + current_app.logger.debug('<<< _validate_user') + ldap_conn = None + try: + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) # pylint: disable=no-member + ldap_conn = ldap.initialize( + current_app.config.get('BCOL_LDAP_SERVER'), trace_level=2 + ) + ldap_conn.set_option(ldap.OPT_REFERRALS, 0) # pylint: disable=no-member + ldap_conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3) # pylint: disable=no-member + ldap_conn.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) # pylint: disable=no-member + ldap_conn.set_option(ldap.OPT_X_TLS_DEMAND, True) # pylint: disable=no-member + ldap_conn.set_option(ldap.OPT_DEBUG_LEVEL, 255) # pylint: disable=no-member + + username = current_app.config.get('BCOL_LDAP_USER_DN_PATTERN').format( + user_id + ) + ldap_conn.simple_bind_s(username, password) + except Exception as error: + current_app.logger.warn(error) + raise BusinessException(Error.BCOL001) + finally: + if ldap_conn: + ldap_conn.unbind_s() + + current_app.logger.debug('>>> _validate_user') + + def __get(self, value: object, key: object) -> str: # pylint: disable=no-self-use + """Get the value from dict and strip.""" + if value and value[key]: + return value[key].strip() + return None + + def get_profile_response(self, data: Dict): # pragma: no cover # pylint: disable=no-self-use + """Get Query Profile Response.""" + client = BcolSoap().get_profile_client() + return zeep.helpers.serialize_object(client.service.queryProfile(req=data)) diff --git a/bcol-api/src/bcol_api/services/bcol_soap.py b/bcol-api/src/bcol_api/services/bcol_soap.py new file mode 100644 index 000000000..4fdc6e209 --- /dev/null +++ b/bcol-api/src/bcol_api/services/bcol_soap.py @@ -0,0 +1,45 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Holder for SOAP from BCOL.""" + +import zeep +from flask import current_app + + +class Singleton(type): + """Singleton meta.""" + + _instances = {} + + def __call__(cls, *args, **kwargs): + """Call for meta.""" + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] + + +class BcolSoap(metaclass=Singleton): # pylint: disable=too-few-public-methods + """Singleton wrapper for BCOL SOAP.""" + + __profile_client = None + + def get_profile_client(self): + """Retrieve singleton Query Profile Client.""" + return self.__profile_client + + def __init__(self): + """Private constructor.""" + self.__profile_client = zeep.Client( + current_app.config.get('BCOL_QUERY_PROFILE_WSDL_URL') + ) diff --git a/bcol-api/src/bcol_api/utils/__init__.py b/bcol-api/src/bcol_api/utils/__init__.py new file mode 100755 index 000000000..9b5291dad --- /dev/null +++ b/bcol-api/src/bcol_api/utils/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""This module holds general utility functions and helpers for the main package.""" diff --git a/bcol-api/src/bcol_api/utils/auth.py b/bcol-api/src/bcol_api/utils/auth.py new file mode 100644 index 000000000..c900a8ab7 --- /dev/null +++ b/bcol-api/src/bcol_api/utils/auth.py @@ -0,0 +1,19 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Bring in the common JWT Manager.""" +from flask_jwt_oidc import JwtManager + + +# lower case name as used by convention in most Flask apps +jwt = JwtManager() # pylint: disable=invalid-name diff --git a/bcol-api/src/bcol_api/utils/constants.py b/bcol-api/src/bcol_api/utils/constants.py new file mode 100644 index 000000000..4d795875e --- /dev/null +++ b/bcol-api/src/bcol_api/utils/constants.py @@ -0,0 +1,42 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Constants.""" +from typing import Dict + + +def auth_code_mapping() -> Dict: + """Return Auth code mapping from BCOL.""" + return { + 'G': 'GDSA', + 'M': 'Master', + 'O': 'Office', + 'P': 'Prime', + 'C': 'Contact', + '': 'Ordinary', + } + + +def account_type_mapping() -> Dict: + """Return Account type mapping from BCOL.""" + return {'B': 'Billable', 'N': 'Non-Billable', 'I': 'Internal'} + + +def tax_status_mapping() -> Dict: + """Return Tax status mapping from BCOL.""" + return {'E': 'Exempt', 'Z': 'Zero-rate', '': 'Must-Pay'} + + +# def status_mapping() -> Dict: +# """Return Status mapping from BCOL.""" +# return {'Y': 'Granted', 'N': 'Revoked'} diff --git a/bcol-api/src/bcol_api/utils/errors.py b/bcol-api/src/bcol_api/utils/errors.py new file mode 100644 index 000000000..b4daf51ae --- /dev/null +++ b/bcol-api/src/bcol_api/utils/errors.py @@ -0,0 +1,30 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Error definitions.""" +from enum import Enum +from http import HTTPStatus + + +class Error(Enum): + """Error Codes.""" + + BCOL001 = 'Invalid User Id or Password', HTTPStatus.BAD_REQUEST + BCOL002 = 'Cannot retrieve user profile', HTTPStatus.BAD_REQUEST + + def __new__(cls, message, status): + """Attributes for the enum.""" + obj = object.__new__(cls) + obj.message = message + obj.status = status + return obj diff --git a/bcol-api/src/bcol_api/utils/logging.py b/bcol-api/src/bcol_api/utils/logging.py new file mode 100755 index 000000000..9c737649a --- /dev/null +++ b/bcol-api/src/bcol_api/utils/logging.py @@ -0,0 +1,31 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Centralized setup of logging for the service.""" +import logging.config +import sys +from os import path + + +def setup_logging(conf): + """Create the services logger. + + TODO should be reworked to load in the proper loggers and remove others + """ + # log_file_path = path.join(path.abspath(path.dirname(__file__)), conf) + + if conf and path.isfile(conf): + logging.config.fileConfig(conf) + print('Configure logging, from conf:{}'.format(conf), file=sys.stdout) + else: + print('Unable to configure logging, attempted conf:{}'.format(conf), file=sys.stderr) diff --git a/bcol-api/src/bcol_api/utils/run_version.py b/bcol-api/src/bcol_api/utils/run_version.py new file mode 100755 index 000000000..c389e5d8e --- /dev/null +++ b/bcol-api/src/bcol_api/utils/run_version.py @@ -0,0 +1,29 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Supply version and commit hash info.""" +import os + +from bcol_api.version import __version__ + + +def _get_build_openshift_commit_hash(): + return os.getenv('OPENSHIFT_BUILD_COMMIT', None) + + +def get_run_version(): + """Return a formatted version string for this service.""" + commit_hash = _get_build_openshift_commit_hash() + if commit_hash: + return f'{__version__}-{commit_hash}' + return __version__ diff --git a/bcol-api/src/bcol_api/utils/trace.py b/bcol-api/src/bcol_api/utils/trace.py new file mode 100644 index 000000000..31cc43080 --- /dev/null +++ b/bcol-api/src/bcol_api/utils/trace.py @@ -0,0 +1,22 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Bring in the Tracer.""" +from sbc_common_components.tracing.api_tracer import ApiTracer +from sbc_common_components.tracing.api_tracing import ApiTracing + + +# initialize tracer +API_TRACER = ApiTracer('BCOL API Services') +tracing = ApiTracing( # pylint: disable=invalid-name; lower case name as used by convention in most Flask apps + API_TRACER.tracer) diff --git a/bcol-api/src/bcol_api/utils/util.py b/bcol-api/src/bcol_api/utils/util.py new file mode 100755 index 000000000..0d111ed72 --- /dev/null +++ b/bcol-api/src/bcol_api/utils/util.py @@ -0,0 +1,33 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""CORS pre-flight decorator. + +A simple decorator to add the options method to a Request Class. +""" + + +def cors_preflight(methods: str = 'GET'): + """Render an option method on the class.""" + def wrapper(f): + def options(self, *args, **kwargs): # pylint: disable=unused-argument + return {'Allow': methods}, 200, \ + {'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': methods, + 'Access-Control-Allow-Headers': 'Authorization, Content-Type, registries-trace-id'} + + setattr(f, 'options', options) + return f + + return wrapper diff --git a/bcol-api/src/bcol_api/version.py b/bcol-api/src/bcol_api/version.py new file mode 100755 index 000000000..9576ba6f8 --- /dev/null +++ b/bcol-api/src/bcol_api/version.py @@ -0,0 +1,25 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Version of this service in PEP440. + +[N!]N(.N)*[{a|b|rc}N][.postN][.devN] +Epoch segment: N! +Release segment: N(.N)* +Pre-release segment: {a|b|rc}N +Post-release segment: .postN +Development release segment: .devN +""" + +__version__ = '0.1.0a0.dev' # pylint: disable=invalid-name diff --git a/bcol-api/tests/__init__.py b/bcol-api/tests/__init__.py new file mode 100755 index 000000000..5b2e2f73f --- /dev/null +++ b/bcol-api/tests/__init__.py @@ -0,0 +1,16 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""The Test Suites to ensure that the service is built and operating correctly.""" + +from .utilities.decorators import skip_in_pod diff --git a/bcol-api/tests/conftest.py b/bcol-api/tests/conftest.py new file mode 100755 index 000000000..f9b502b6b --- /dev/null +++ b/bcol-api/tests/conftest.py @@ -0,0 +1,137 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Common setup and fixtures for the py-test suite used by this service.""" + +import random +from unittest.mock import patch + +import pytest + +from bcol_api import create_app +from bcol_api import jwt as _jwt +from tests.utilities.ldap_mock import MockLDAP + + +@pytest.fixture(scope='session') +def app(): + """Return a session-wide application configured in TEST mode.""" + _app = create_app('testing') + + return _app + + +@pytest.fixture(scope='function') +def app_request(): + """Return a session-wide application configured in TEST mode.""" + _app = create_app('testing') + + return _app + + +@pytest.fixture(scope='session') +def client(app): # pylint: disable=redefined-outer-name + """Return a session-wide Flask test client.""" + return app.test_client() + + +@pytest.fixture(scope='session') +def jwt(app): + """Return session-wide jwt manager.""" + return _jwt + + +@pytest.fixture(scope='session') +def client_ctx(app): + """Return session-wide Flask test client.""" + with app.test_client() as _client: + yield _client + + +@pytest.fixture('function') +def client_id(): + """Return a unique client_id that can be used in tests.""" + _id = random.SystemRandom().getrandbits(0x58) + # _id = (base64.urlsafe_b64encode(uuid.uuid4().bytes)).replace('=', '') + + return f'client-{_id}' + + +@pytest.fixture() +def ldap_mock(): + """Mock ldap.""" + ldap_patcher = patch('bcol_api.services.bcol_service.ldap.initialize') + _mock_ldap = MockLDAP() + mock_ldap = ldap_patcher.start() + mock_ldap.return_value = _mock_ldap + yield + ldap_patcher.stop() + + +@pytest.fixture() +def ldap_mock_error(): + """Mock ldap error.""" + ldap_patcher = patch('bcol_api.services.bcol_service.ldap.initialize', side_effect=Exception('Mocked Error')) + _mock_ldap = MockLDAP() + mock_ldap = ldap_patcher.start() + mock_ldap.return_value = _mock_ldap + yield + ldap_patcher.stop() + + +@pytest.fixture() +def query_profile_mock(): + """Mock Query Profile SOAP.""" + mock_query_profile_patcher = patch( + 'bcol_api.services.bcol_service.BcolService.get_profile_response' + ) + mock_query_profile = mock_query_profile_patcher.start() + mock_query_profile.return_value = { + 'Userid': 'PB25020', + 'AccountNumber': '1234567890', + 'AuthCode': 'M', + 'AccountType': 'B', + 'GSTStatus': ' ', + 'PSTStatus': ' ', + 'UserName': 'Test, Test', + 'Address': { + 'AddressA': '#400A - 4000 SEYMOUR PLACE', + 'AddressB': 'PENTHOUSE', + 'City': 'AB1', + 'Prov': 'BC', + 'Country': 'CANADA', + 'PostalCode': 'V8X 5J8', + }, + 'UserPhone': '(250)953-8271 EX1999', + 'UserFax': '(250)953-8212', + 'Status': 'Y', + 'org-name': 'BC ONLINE TECHNICAL TEAM DEVL', + 'org-type': 'LAW', + 'queryProfileFlag': [{'name': 'TEST'}], + } + + yield + mock_query_profile_patcher.stop() + + +@pytest.fixture() +def query_profile_mock_error(): + """Mock Query Profile SOAP.""" + mock_query_profile_patcher = patch( + 'bcol_api.services.bcol_service.BcolService.get_profile_response', side_effect=Exception('Mocked Error') + ) + mock_query_profile_patcher.start() + + yield + mock_query_profile_patcher.stop() diff --git a/bcol-api/tests/unit/__init__.py b/bcol-api/tests/unit/__init__.py new file mode 100755 index 000000000..d755f6a1d --- /dev/null +++ b/bcol-api/tests/unit/__init__.py @@ -0,0 +1,18 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""The Unit Test for the API. + +For our purposes this server and its Postgres Database are part of the Unit Test Suite. +""" diff --git a/bcol-api/tests/unit/api/__init__.py b/bcol-api/tests/unit/api/__init__.py new file mode 100755 index 000000000..02d5354ad --- /dev/null +++ b/bcol-api/tests/unit/api/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Test-Suite for the API.""" diff --git a/bcol-api/tests/unit/api/test_bcol_profile.py b/bcol-api/tests/unit/api/test_bcol_profile.py new file mode 100755 index 000000000..c5132adaf --- /dev/null +++ b/bcol-api/tests/unit/api/test_bcol_profile.py @@ -0,0 +1,53 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Tests to assure the bcol accounts end-point. + +Test-Suite to ensure that the /accounts//users/ endpoint is working as expected. +""" +import json + +from tests.utilities.base_test import get_claims, get_token_header + + +def test_post_accounts(client, jwt, app, ldap_mock, query_profile_mock): + """Assert that the endpoint returns 200.""" + token = jwt.create_jwt(get_claims(), get_token_header()) + headers = {'content-type': 'application/json', 'Authorization': f'Bearer {token}'} + rv = client.post('/api/v1/accounts', data=json.dumps({'userId': 'TEST', 'password': 'TEST'}), headers=headers) + assert rv.status_code == 200 + + +def test_post_accounts_invalid_request(client, jwt, app, ldap_mock, query_profile_mock): + """Assert that the endpoint returns 200.""" + token = jwt.create_jwt(get_claims(), get_token_header()) + headers = {'content-type': 'application/json', 'Authorization': f'Bearer {token}'} + rv = client.post('/api/v1/accounts', data=json.dumps({'user': 'TEST', 'password': 'TEST'}), headers=headers) + assert rv.status_code == 400 + + +def test_post_accounts_auth_error(client, jwt, app, ldap_mock_error, query_profile_mock): + """Assert that the endpoint returns 200.""" + token = jwt.create_jwt(get_claims(), get_token_header()) + headers = {'content-type': 'application/json', 'Authorization': f'Bearer {token}'} + rv = client.post('/api/v1/accounts', data=json.dumps({'userId': 'TEST', 'password': 'TEST'}), headers=headers) + assert rv.status_code == 400 + + +def test_post_accounts_query_error(client, jwt, app, ldap_mock, query_profile_mock_error): + """Assert that the endpoint returns 200.""" + token = jwt.create_jwt(get_claims(), get_token_header()) + headers = {'content-type': 'application/json', 'Authorization': f'Bearer {token}'} + rv = client.post('/api/v1/accounts', data=json.dumps({'userId': 'TEST', 'password': 'TEST'}), headers=headers) + assert rv.status_code == 400 diff --git a/bcol-api/tests/unit/api/test_meta.py b/bcol-api/tests/unit/api/test_meta.py new file mode 100755 index 000000000..b24d8e9ca --- /dev/null +++ b/bcol-api/tests/unit/api/test_meta.py @@ -0,0 +1,42 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Tests to assure the meta end-point. + +Test-Suite to ensure that the /meta endpoint is working as expected. +""" +from tests import skip_in_pod + + +@skip_in_pod +def test_meta_no_commit_hash(client): + """Assert that the endpoint returns just the services __version__.""" + from bcol_api.version import __version__ + + rv = client.get('/api/v1/meta/info') + + assert rv.status_code == 200 + assert rv.json == {'API': f'bcol_api/{__version__}'} + + +def test_meta_with_commit_hash(monkeypatch, client): + """Assert that the endpoint return __version__ and the last git hash used to build the services image.""" + from bcol_api.version import __version__ + + commit_hash = 'deadbeef_ha' + monkeypatch.setenv('OPENSHIFT_BUILD_COMMIT', commit_hash) + + rv = client.get('/api/v1/meta/info') + assert rv.status_code == 200 + assert rv.json == {'API': f'bcol_api/{__version__}-{commit_hash}'} diff --git a/bcol-api/tests/unit/api/test_ops.py b/bcol-api/tests/unit/api/test_ops.py new file mode 100755 index 000000000..a1aa7b28d --- /dev/null +++ b/bcol-api/tests/unit/api/test_ops.py @@ -0,0 +1,35 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +""" +Tests to assure the ops end-point. + +Test-Suite to ensure that the /ops endpoint is working as expected. +""" + + +def test_ops_healthz_success(client): + """Assert that the service is healthy if it can successfully access the database.""" + rv = client.get('/ops/healthz') + + assert rv.status_code == 200 + assert rv.json == {'message': 'api is healthy'} + + +def test_ops_readyz(client): + """Asserts that the service is ready to serve.""" + rv = client.get('/ops/readyz') + + assert rv.status_code == 200 + assert rv.json == {'message': 'api is ready'} diff --git a/bcol-api/tests/unit/conf/__init__.py b/bcol-api/tests/unit/conf/__init__.py new file mode 100755 index 000000000..47a9b4f52 --- /dev/null +++ b/bcol-api/tests/unit/conf/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Test-Suite for the configuration system.""" diff --git a/bcol-api/tests/unit/conf/test_configuration.py b/bcol-api/tests/unit/conf/test_configuration.py new file mode 100755 index 000000000..66d103add --- /dev/null +++ b/bcol-api/tests/unit/conf/test_configuration.py @@ -0,0 +1,66 @@ +# Copyright © 2019 Province of British Columbia. +# +# 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. +"""Tests to assure the configuration objects. + +Test-Suite to ensure that the Configuration Classes are working as expected. +""" +from importlib import reload + +import pytest + +import config + + +# testdata pattern is ({str: environment}, {expected return value}) +TEST_ENVIRONMENT_DATA = [ + ('valid', 'development', config.DevConfig), + ('valid', 'testing', config.TestConfig), + ('valid', 'default', config.ProdConfig), + ('valid', 'staging', config.ProdConfig), + ('valid', 'production', config.ProdConfig), + ('error', None, KeyError) +] + + +@pytest.mark.parametrize('test_type,environment,expected', TEST_ENVIRONMENT_DATA) +def test_get_named_config(test_type, environment, expected): + """Assert that the named configurations can be loaded. + + Or that a KeyError is returned for missing config types. + """ + if test_type == 'valid': + assert isinstance(config.get_named_config(environment), expected) + else: + with pytest.raises(KeyError): + config.get_named_config(environment) + + +def test_prod_config_secret_key(monkeypatch): # pylint: disable=missing-docstring + """Assert that the ProductionConfig is correct. + + The object either uses the SECRET_KEY from the environment, + or creates the SECRET_KEY on the fly. + """ + key = 'SECRET_KEY' + + # Assert that secret key will default to some value + # even if missed in the environment setup + monkeypatch.delenv(key, raising=False) + reload(config) + assert config.ProdConfig().SECRET_KEY is not None + + # Assert that the secret_key is set to the assigned environment value + monkeypatch.setenv(key, 'SECRET_KEY') + reload(config) + assert config.ProdConfig().SECRET_KEY == 'SECRET_KEY' diff --git a/bcol-api/tests/unit/conf/test_version.py b/bcol-api/tests/unit/conf/test_version.py new file mode 100755 index 000000000..34d289e0a --- /dev/null +++ b/bcol-api/tests/unit/conf/test_version.py @@ -0,0 +1,28 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Tests to assure the version utilities. + +Test-Suite to ensure that the version utilities are working as expected. +""" +from bcol_api import utils +from bcol_api.version import __version__ +from tests import skip_in_pod + + +@skip_in_pod +def test_get_version(): + """Assert thatThe version is returned correctly.""" + rv = utils.run_version.get_run_version() + assert rv == __version__ diff --git a/bcol-api/tests/unit/services/test_bcol_service.py b/bcol-api/tests/unit/services/test_bcol_service.py new file mode 100644 index 000000000..f54209c7e --- /dev/null +++ b/bcol-api/tests/unit/services/test_bcol_service.py @@ -0,0 +1,27 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Tests to assure the BCOL service layer. + +Test-Suite to ensure that the BCOL Service layer is working as expected. +""" + +from bcol_api.services.bcol_service import BcolService + + +def test_query_profile(app, ldap_mock, query_profile_mock): + """Test query profile service.""" + with app.app_context(): + query_profile_response = BcolService().query_profile('TEST', 'TEST') + assert query_profile_response.get('userId') == 'PB25020' diff --git a/bcol-api/tests/unit/services/test_bcol_soap.py b/bcol-api/tests/unit/services/test_bcol_soap.py new file mode 100644 index 000000000..9d963c10e --- /dev/null +++ b/bcol-api/tests/unit/services/test_bcol_soap.py @@ -0,0 +1,36 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Tests to assure the BCOL service layer. + +Test-Suite to ensure that the BCOL Service layer is working as expected. +""" + +from bcol_api.services.bcol_soap import BcolSoap + + +def test_bcol_soap(app): + """Test BCOL SOAP Initialization.""" + with app.app_context(): + bcol_soap = BcolSoap() + assert bcol_soap is not None + assert bcol_soap.get_profile_client() is not None + + +def test_bcol_soap_multiple_instances(app): + """Test BCOL SOAP Initialization for multiple instances.""" + with app.app_context(): + bcol_soap = BcolSoap() + bcol_soap2 = BcolSoap() + assert bcol_soap == bcol_soap2 diff --git a/bcol-api/tests/unit/utils/__init__.py b/bcol-api/tests/unit/utils/__init__.py new file mode 100755 index 000000000..a41ec6419 --- /dev/null +++ b/bcol-api/tests/unit/utils/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Test Suite for the Utils package.""" diff --git a/bcol-api/tests/unit/utils/logging.conf b/bcol-api/tests/unit/utils/logging.conf new file mode 100755 index 000000000..ffc1a01e3 --- /dev/null +++ b/bcol-api/tests/unit/utils/logging.conf @@ -0,0 +1,28 @@ +[loggers] +keys=root,api + +[handlers] +keys=console + +[formatters] +keys=simple + +[logger_root] +level=DEBUG +handlers=console + +[logger_api] +level=DEBUG +handlers=console +qualname=api +propagate=0 + +[handler_console] +class=StreamHandler +level=DEBUG +formatter=simple +args=(sys.stdout,) + +[formatter_simple] +format=%(asctime)s - %(name)s - %(levelname)s in %(module)s:%(filename)s:%(lineno)d - %(funcName)s: %(message)s +datefmt= \ No newline at end of file diff --git a/bcol-api/tests/unit/utils/test_logging.py b/bcol-api/tests/unit/utils/test_logging.py new file mode 100755 index 000000000..6283e0dfc --- /dev/null +++ b/bcol-api/tests/unit/utils/test_logging.py @@ -0,0 +1,42 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Tests to assure the logging utilities. + +Test-Suite to ensure that the logging setup is working as expected. +""" + +import os + +from bcol_api.utils.logging import setup_logging + + +def test_logging_with_file(capsys): + """Assert that logging is setup with the configuration file.""" + file_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'logging.conf') + setup_logging(file_path) # important to do this first + + captured = capsys.readouterr() + + assert captured.out.startswith('Configure logging, from conf') + + +def test_logging_with_missing_file(capsys): + """Assert that a message is sent to STDERR when the configuration doesn't exist.""" + file_path = None + setup_logging(file_path) # important to do this first + + captured = capsys.readouterr() + + assert captured.err.startswith('Unable to configure logging') diff --git a/bcol-api/tests/unit/utils/test_util_cors.py b/bcol-api/tests/unit/utils/test_util_cors.py new file mode 100755 index 000000000..210ce8bfa --- /dev/null +++ b/bcol-api/tests/unit/utils/test_util_cors.py @@ -0,0 +1,45 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Tests to assure the CORS utilities. + +Test-Suite to ensure that the CORS decorator is working as expected. +""" +import pytest + +from bcol_api.utils.util import cors_preflight + + +TEST_CORS_METHODS_DATA = [ + ('GET'), + ('PUT'), + ('POST'), + ('GET,PUT'), + ('GET,POST'), + ('PUT,POST'), + ('GET,PUT,POST'), +] + + +@pytest.mark.parametrize('methods', TEST_CORS_METHODS_DATA) +def test_cors_preflight_post(methods): + """Assert that the options methos is added to the class and that the correct access controls are set.""" + @cors_preflight(methods) # pylint: disable=too-few-public-methods + class TestCors(): + pass + + rv = TestCors().options() # pylint: disable=no-member + + assert rv[2]['Access-Control-Allow-Origin'] == '*' + assert rv[2]['Access-Control-Allow-Methods'] == methods diff --git a/bcol-api/tests/utilities/__init__.py b/bcol-api/tests/utilities/__init__.py new file mode 100644 index 000000000..eb431264d --- /dev/null +++ b/bcol-api/tests/utilities/__init__.py @@ -0,0 +1,15 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Test utils.""" diff --git a/bcol-api/tests/utilities/base_test.py b/bcol-api/tests/utilities/base_test.py new file mode 100644 index 000000000..d7f3df4ba --- /dev/null +++ b/bcol-api/tests/utilities/base_test.py @@ -0,0 +1,48 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Base Test Class to be used by test suites.""" + + +def get_token_header(): + """Get the token header json.""" + return { + 'alg': 'RS256', + 'typ': 'JWT', + 'kid': 'sbc-auth-cron-job' + } + + +def get_claims(app_request=None, role: str = 'edit', username: str = 'CP0001234', login_source: str = 'PASSCODE'): + """Return the claim with the role param.""" + claim = { + 'jti': 'a50fafa4-c4d6-4a9b-9e51-1e5e0d102878', + 'exp': 31531718745, + 'iat': 1531718745, + 'iss': app_request.config[ + 'JWT_OIDC_ISSUER'] if app_request else 'https://sso-dev.pathfinder.gov.bc.ca/auth/realms/fcf0kpqr', + 'aud': 'sbc-auth-web', + 'sub': '15099883-3c3f-4b4c-a124-a1824d6cba84', + 'typ': 'Bearer', + 'realm_access': + { + 'roles': + [ + '{}'.format(role) + ] + }, + 'preferred_username': username, + 'username': username, + 'loginSource': login_source + } + return claim diff --git a/bcol-api/tests/utilities/decorators.py b/bcol-api/tests/utilities/decorators.py new file mode 100644 index 000000000..88deec93f --- /dev/null +++ b/bcol-api/tests/utilities/decorators.py @@ -0,0 +1,24 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""decorators used to skip/run pytests based on local setup.""" +import os + +import pytest +from dotenv import find_dotenv, load_dotenv + + +# this will load all the envars from a .env file located in the project root (api) +load_dotenv(find_dotenv()) + +skip_in_pod = pytest.mark.skipif(os.getenv('POD_TESTING', False), reason='Skip test when running in pod') diff --git a/bcol-api/tests/utilities/ldap_mock.py b/bcol-api/tests/utilities/ldap_mock.py new file mode 100644 index 000000000..64dd97dd6 --- /dev/null +++ b/bcol-api/tests/utilities/ldap_mock.py @@ -0,0 +1,45 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Mock LDAP Class to be used in unit tests.""" +import sys +from collections import defaultdict + + +class MockLDAP(object): + """Mock LDAP Class. It just passes everything, no fancy dynamic stuff here!!.""" + + def __init__(self): + """Init.""" + self.directory = defaultdict(lambda: {}) + + def set_option(self, option, invalue): + """Set option value.""" + + def initialize( + self, uri, trace_level=0, trace_file=sys.stdout, trace_stack_limit=None + ): + """Initialize ldap.""" + + def simple_bind_s(self, who='', cred=''): + """Bind.""" + + def unbind_s(self): + """Unbind.""" + + def search_s( + self, base, scope, filterstr='(objectClass=*)', attrlist=None, attrsonly=0 + ): + """Search.""" + return 'TEST_USER' diff --git a/bcol-api/tests/utilities/schema_assertions.py b/bcol-api/tests/utilities/schema_assertions.py new file mode 100755 index 000000000..24d3d1f8e --- /dev/null +++ b/bcol-api/tests/utilities/schema_assertions.py @@ -0,0 +1,37 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. + +"""Utilities to load and validate against JSONSchemas. + +Test helper functions to load and assert that a JSON payload validates against a defined schema. +""" +import json +from os.path import dirname, join + +from jsonschema import validate + + +def assert_valid_schema(data: dict, schema_file: dict): + """Do assertion that data validates against the JSONSchema in schema_file.""" + schema = _load_json_schema(schema_file) + return validate(data, schema) + + +def _load_json_schema(filename: str): + """Return the given schema file identified by filename.""" + relative_path = join('schemas', filename) + absolute_path = join(dirname(__file__), relative_path) + + with open(absolute_path) as schema_file: + return json.loads(schema_file.read()) diff --git a/bcol-api/wsgi.py b/bcol-api/wsgi.py new file mode 100755 index 000000000..86a4b005d --- /dev/null +++ b/bcol-api/wsgi.py @@ -0,0 +1,22 @@ +# Copyright © 2019 Province of British Columbia +# +# 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. +"""Provides the WSGI entry point for running the application +""" +from bcol_api import create_app + +# Openshift s2i expects a lower case name of application +application = create_app() # pylint: disable=invalid-name + +if __name__ == "__main__": + application.run() From 7e167522b9a2f70746e0f22d3d85aa4cf5ffd4e5 Mon Sep 17 00:00:00 2001 From: Sumesh Punakkal Kariyil Date: Mon, 4 Nov 2019 10:07:09 -0800 Subject: [PATCH 3/6] Staff Receipt Changes Changes to receipt for adding routing slip number Linting fixes --- .../pay_api/services/internal_pay_service.py | 2 +- pay-api/src/pay_api/services/receipt.py | 8 +++++++- pay-api/tests/unit/api/test_receipt.py | 20 ++++++++++++++++++- .../payment_receipt_coops.html | 5 +++++ report-api/src/api/utils/constants.py | 1 - 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pay-api/src/pay_api/services/internal_pay_service.py b/pay-api/src/pay_api/services/internal_pay_service.py index 4ead18ba8..57dd4846b 100644 --- a/pay-api/src/pay_api/services/internal_pay_service.py +++ b/pay-api/src/pay_api/services/internal_pay_service.py @@ -50,7 +50,7 @@ def create_invoice(self, payment_account: PaymentAccount, line_items: [PaymentLi current_app.logger.debug('create_invoice') diff --git a/pay-api/src/pay_api/services/receipt.py b/pay-api/src/pay_api/services/receipt.py index b3f0fc278..1a4c1e1ba 100644 --- a/pay-api/src/pay_api/services/receipt.py +++ b/pay-api/src/pay_api/services/receipt.py @@ -21,7 +21,7 @@ from pay_api.exceptions import BusinessException from pay_api.models import Receipt as ReceiptModel -from pay_api.utils.enums import AuthHeaderType, ContentType +from pay_api.utils.enums import AuthHeaderType, ContentType, PaymentSystem from pay_api.utils.errors import Error from .invoice import Invoice @@ -163,6 +163,12 @@ def create_receipt(payment_identifier: str, invoice_identifier: str, filing_data template_vars['incorporationNumber'] = payment_account.corp_number template_vars['paymentInvoiceNumber'] = invoice_data.invoice_number + + if payment_account.payment_system_code == PaymentSystem.INTERNAL.value and invoice_data.routing_slip: + template_vars['routingSlipNumber'] = invoice_data.routing_slip + else: + template_vars['displayRoutingSlip'] = 'none' + if not invoice_data.receipts: raise BusinessException(Error.PAY999) diff --git a/pay-api/tests/unit/api/test_receipt.py b/pay-api/tests/unit/api/test_receipt.py index 6963798f0..102079b77 100644 --- a/pay-api/tests/unit/api/test_receipt.py +++ b/pay-api/tests/unit/api/test_receipt.py @@ -20,7 +20,8 @@ import json import pytest -from tests.utilities.base_test import get_claims, get_payment_request, token_header + +from tests.utilities.base_test import get_claims, get_payment_request, token_header, get_zero_dollar_payment_request @pytest.fixture @@ -133,3 +134,20 @@ def test_receipt_creation_with_invalid_identifiers(session, client, jwt, app): data=json.dumps(filing_data), headers=headers) assert rv.status_code == 400 + + +def test_receipt_creation_for_internal_payments(session, client, jwt, app): + """Assert that the endpoint returns 201.""" + token = jwt.create_jwt(get_claims(app_request=app), token_header) + headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} + + rv = client.post(f'/api/v1/payment-requests', data=json.dumps(get_zero_dollar_payment_request()), headers=headers) + pay_id = rv.json.get('id') + + filing_data = { + 'corpName': 'CP0001234', + 'filingDateTime': 'June 27, 2019', + 'fileName': 'director-change' + } + rv = client.post(f'/api/v1/payment-requests/{pay_id}/receipts', data=json.dumps(filing_data), headers=headers) + assert rv.status_code == 201 diff --git a/report-api/report-templates/payment_receipt_coops.html b/report-api/report-templates/payment_receipt_coops.html index 23c47fc07..c15892205 100644 --- a/report-api/report-templates/payment_receipt_coops.html +++ b/report-api/report-templates/payment_receipt_coops.html @@ -75,6 +75,7 @@ border: none; border-collapse: collapse; table-layout: fixed; + font-size: 14px; } .receipt-details td { @@ -196,6 +197,10 @@ Receipt Number: {{ receiptNumber }} + + Routing Slip Number: + {{ routingSlipNumber }} + Date: Tue, 5 Nov 2019 12:56:47 -0800 Subject: [PATCH 4/6] #1748: Changes for adding filing id to the payment token --- ...61324fa9b37_adding_filing_id_to_invoice.py | 28 +++++++++++++++++++ pay-api/src/pay_api/models/invoice.py | 1 + .../pay_api/schemas/schemas/filing_info.json | 5 ++++ pay-api/src/pay_api/services/invoice.py | 20 ++++++++++--- .../src/pay_api/services/payment_service.py | 4 ++- .../pay_api/services/payment_transaction.py | 7 +++-- 6 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 pay-api/migrations/versions/861324fa9b37_adding_filing_id_to_invoice.py diff --git a/pay-api/migrations/versions/861324fa9b37_adding_filing_id_to_invoice.py b/pay-api/migrations/versions/861324fa9b37_adding_filing_id_to_invoice.py new file mode 100644 index 000000000..6fcee8bc0 --- /dev/null +++ b/pay-api/migrations/versions/861324fa9b37_adding_filing_id_to_invoice.py @@ -0,0 +1,28 @@ +"""adding_filing_id_to_invoice + +Revision ID: 861324fa9b37 +Revises: e6eb14b9d50e +Create Date: 2019-11-04 15:41:02.521095 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '861324fa9b37' +down_revision = 'e6eb14b9d50e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('invoice', sa.Column('filing_id', sa.String(length=50), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('invoice', 'filing_id') + # ### end Alembic commands ### diff --git a/pay-api/src/pay_api/models/invoice.py b/pay-api/src/pay_api/models/invoice.py index c34190191..19e835573 100644 --- a/pay-api/src/pay_api/models/invoice.py +++ b/pay-api/src/pay_api/models/invoice.py @@ -44,6 +44,7 @@ class Invoice(db.Model, Audit, BaseModel): # pylint: disable=too-many-instance- payment_date = db.Column(db.DateTime, nullable=True) refund = db.Column(db.Float, nullable=True) routing_slip = db.Column(db.String(50), nullable=True) + filing_id = db.Column(db.String(50), nullable=True) payment_line_items = relationship('PaymentLineItem') receipts = relationship('Receipt') diff --git a/pay-api/src/pay_api/schemas/schemas/filing_info.json b/pay-api/src/pay_api/schemas/schemas/filing_info.json index 3ea2a6944..e22c15c97 100644 --- a/pay-api/src/pay_api/schemas/schemas/filing_info.json +++ b/pay-api/src/pay_api/schemas/schemas/filing_info.json @@ -7,6 +7,11 @@ "filingTypes" ], "properties": { + "filingIdentifier": { + "$id": "#/properties/filingIdentifier", + "type": "string", + "title": "Filing identifier from the filing system" + }, "date": { "$id": "#/properties/date", "type": "string", diff --git a/pay-api/src/pay_api/services/invoice.py b/pay-api/src/pay_api/services/invoice.py index 34a2b2805..854a7c1ff 100644 --- a/pay-api/src/pay_api/services/invoice.py +++ b/pay-api/src/pay_api/services/invoice.py @@ -53,6 +53,7 @@ def __init__(self): self._payment_account = None self._receipts = None self._routing_slip: str = None + self._filing_id: str = None @property def _dao(self): @@ -81,6 +82,7 @@ def _dao(self, value): self.payment_account = self._dao.account self.receipts = self._dao.receipts self.routing_slip: str = self._dao.routing_slip + self.filing_id: str = self._dao.filing_id @property def id(self): @@ -280,6 +282,17 @@ def routing_slip(self, value: str): self._routing_slip = value self._dao.routing_slip = value + @property + def filing_id(self): + """Return the filing_id.""" + return self._filing_id + + @filing_id.setter + def filing_id(self, value: str): + """Set the filing_id.""" + self._filing_id = value + self._dao.filing_id = value + def save(self): """Save the information to the DB.""" return self._dao.save() @@ -303,8 +316,7 @@ def populate(value): return invoice @staticmethod - def create(account: PaymentAccount, payment_id: int, fees: [FeeSchedule], current_user: str, - routing_slip: str = None): + def create(account: PaymentAccount, payment_id: int, fees: [FeeSchedule], current_user: str, **kwargs): """Create invoice record.""" current_app.logger.debug('create') diff --git a/pay-api/src/pay_api/services/payment_service.py b/pay-api/src/pay_api/services/payment_service.py index 263d215ea..f80f6410f 100644 --- a/pay-api/src/pay_api/services/payment_service.py +++ b/pay-api/src/pay_api/services/payment_service.py @@ -62,6 +62,7 @@ def create_payment(cls, payment_request: Tuple[Dict[str, Any]], token_info: Dict filing_info = payment_request.get('filingInfo') account_info = payment_request.get('accountInfo', None) routing_slip_number = account_info.get('routingSlip', None) if account_info else None + filing_id = filing_info.get('filingIdentifier', None) corp_type = business_info.get('corpType', None) payment_method = payment_info.get('methodOfPayment', None) @@ -88,7 +89,8 @@ def create_payment(cls, payment_request: Tuple[Dict[str, Any]], token_info: Dict current_app.logger.debug(payment) current_app.logger.debug('Creating Invoice record for payment {}'.format(payment.id)) - invoice = Invoice.create(payment_account, payment.id, fees, current_user, routing_slip_number) + invoice = Invoice.create(payment_account, payment.id, fees, current_user, routing_slip=routing_slip_number, + filing_id=filing_id) line_items = [] for fee in fees: diff --git a/pay-api/src/pay_api/services/payment_transaction.py b/pay-api/src/pay_api/services/payment_transaction.py index d9c0825b4..efcae89d7 100644 --- a/pay-api/src/pay_api/services/payment_transaction.py +++ b/pay-api/src/pay_api/services/payment_transaction.py @@ -328,7 +328,7 @@ def update_transaction(payment_identifier: int, transaction_id: uuid, # pylint: transaction_dao.transaction_end_time = datetime.now() # Publish status to Queue - PaymentTransaction.publish_status(transaction_dao, payment) + PaymentTransaction.publish_status(transaction_dao, payment, invoice.filing_id) transaction_dao = transaction_dao.save() @@ -352,7 +352,7 @@ def find_by_payment_id(payment_identifier: int): return data @staticmethod - def publish_status(transaction_dao: PaymentTransactionModel, payment: Payment): + def publish_status(transaction_dao: PaymentTransactionModel, payment: Payment, filing_id: str = None): """Publish payment/transaction status to the Queue.""" current_app.logger.debug(' Date: Wed, 6 Nov 2019 11:25:57 -0800 Subject: [PATCH 5/6] Changing logger configuration to disable sql alchemy echoes and to add payload to payment requests --- pay-api/config.py | 2 +- pay-api/src/pay_api/resources/payment.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pay-api/config.py b/pay-api/config.py index 095dc6b12..d4aaf13e8 100755 --- a/pay-api/config.py +++ b/pay-api/config.py @@ -67,7 +67,7 @@ class _Config(object): # pylint: disable=too-few-public-methods SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( user=DB_USER, password=DB_PASSWORD, host=DB_HOST, port=int(DB_PORT), name=DB_NAME ) - SQLALCHEMY_ECHO = True + SQLALCHEMY_ECHO = False # JWT_OIDC Settings JWT_OIDC_WELL_KNOWN_CONFIG = os.getenv('JWT_OIDC_WELL_KNOWN_CONFIG') diff --git a/pay-api/src/pay_api/resources/payment.py b/pay-api/src/pay_api/resources/payment.py index 9b50f608f..6e65d609d 100644 --- a/pay-api/src/pay_api/resources/payment.py +++ b/pay-api/src/pay_api/resources/payment.py @@ -43,7 +43,7 @@ def post(): """Create the payment records.""" current_app.logger.info(' Date: Fri, 8 Nov 2019 15:29:21 -0800 Subject: [PATCH 6/6] 1806: Fixing issue with payment requests for BCORP --- pay-api/src/pay_api/factory/payment_system_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pay-api/src/pay_api/factory/payment_system_factory.py b/pay-api/src/pay_api/factory/payment_system_factory.py index fdb775b02..b4ed6f922 100644 --- a/pay-api/src/pay_api/factory/payment_system_factory.py +++ b/pay-api/src/pay_api/factory/payment_system_factory.py @@ -64,7 +64,7 @@ def create(token_info: Dict = None, **kwargs): and Role.STAFF.value in token_info['realm_access']['roles']): _instance = InternalPayService() else: - if payment_method == 'CC' and corp_type == 'CP': + if payment_method == 'CC': _instance = PaybcService() if not _instance: