From b415ed7f2168a6580dbd359bd86da42551bfe235 Mon Sep 17 00:00:00 2001 From: James Elson Date: Tue, 11 Feb 2025 16:37:36 -0800 Subject: [PATCH 1/6] Move gwa status logic to kubeapi --- .../gatewayApi/v2/routes/gw_status.py | 188 +++++------------- microservices/kubeApi/routers/routes.py | 149 +++++++++++++- 2 files changed, 196 insertions(+), 141 deletions(-) diff --git a/microservices/gatewayApi/v2/routes/gw_status.py b/microservices/gatewayApi/v2/routes/gw_status.py index 11870995..6d099139 100644 --- a/microservices/gatewayApi/v2/routes/gw_status.py +++ b/microservices/gatewayApi/v2/routes/gw_status.py @@ -1,14 +1,12 @@ import requests import sys import traceback -import urllib3 -import certifi -import socket -from urllib.parse import urlparse from flask import Blueprint, jsonify, request, Response, make_response, abort, g, current_app as app +from werkzeug.exceptions import HTTPException from v2.auth.auth import admin_jwt, uma_enforce - +from v2.services.namespaces import NamespaceService +from utils.get_data_plane import get_data_plane from clients.kong import get_services_by_ns, get_routes_by_ns gw_status = Blueprint('gw_status_v2', 'gw_status') @@ -23,138 +21,48 @@ def get_statuses(namespace: str) -> object: log.info("Get status for %s" % namespace) - services = get_services_by_ns (namespace) - routes = get_routes_by_ns (namespace) - - response = [] - - for service in services: - url = build_url (service) - status = "UP" - reason = "" - - actual_host = None - host = None - for route in routes: - if route['service']['id'] == service['id'] and 'hosts' in route: - actual_host = route['hosts'][0] - if route['preserve_host']: - host = clean_host(actual_host) - - try: - addr = socket.gethostbyname(service['host']) - log.info("Address = %s" % addr) - except: - status = "DOWN" - reason = "DNS" - - if status == "UP": - try: - headers = {} - if host is None or service['host'].endswith('.svc'): - r = requests.get(url, headers=headers, timeout=3.0) - status_code = r.status_code - else: - u = urlparse(url) - - if host is None: - headers['Host'] = u.hostname - else: - headers['Host'] = host - - log.info("GET %-30s %s" % ("%s://%s" % (u.scheme, u.netloc), headers)) - - urllib3.disable_warnings() - if u.scheme == "https": - pool = urllib3.HTTPSConnectionPool( - "%s" % (u.netloc), - assert_hostname=host, - server_hostname=host, - cert_reqs='CERT_NONE', - ca_certs=certifi.where() - ) - else: - pool = urllib3.HTTPConnectionPool( - "%s" % (u.netloc) - ) - req = pool.urlopen( - "GET", - u.path, - headers={"Host": host}, - assert_same_host=False, - timeout=1.0, - retries=False - ) - - status_code = req.status - - log.info("Result received!! %d" % status_code) - if status_code < 400: - status = "UP" - reason = "%d Response" % status_code - elif status_code == 401 or status_code == 403: - status = "UP" - reason = "AUTH %d" % status_code - else: - status = "DOWN" - reason = "%d Response" % status_code - except requests.exceptions.Timeout as ex: - status = "DOWN" - reason = "TIMEOUT" - except urllib3.exceptions.ConnectTimeoutError as ex: - status = "DOWN" - reason = "TIMEOUT" - except requests.exceptions.ConnectionError as ex: - log.error("ConnError %s" % ex) - status = "DOWN" - reason = "CONNECTION" - except requests.exceptions.SSLError as ex: - status = "DOWN" - reason = "SSL" - except urllib3.exceptions.NewConnectionError as ex: - log.error("NewConnError %s" % ex) - status = "DOWN" - reason = "CON_ERR" - except urllib3.exceptions.SSLError as ex: - log.error(ex) - status = "DOWN" - reason = "SSL_URLLIB3" - except Exception as ex: - log.error(ex) - traceback.print_exc(file=sys.stdout) - status = "DOWN" - reason = "UNKNOWN" - - log.info("GET %-30s %s" % (url,reason)) - response.append({"name": service['name'], "upstream": url, "status": status, "reason": reason, "host": host, "env_host": actual_host}) - - return make_response(jsonify(response)) - -def build_url (s): - schema = default(s, "protocol", "http") - defaultPort = 80 - if schema == "https": - defaultPort = 443 - host = s['host'] - port = default(s, "port", defaultPort) - path = default(s, "path", "/") - if 'url' in s: - return s['url'] - else: - return "%s://%s:%d%s" % (schema, host, port, path) - - -def default (s, key, val): - if key in s and s[key] is not None: - return s[key] - else: - return val - - -def clean_host (host): - conf = app.config['hostTransformation'] - if conf['enabled'] is True: - conf = app.config['hostTransformation'] - return host.replace(conf['baseUrl'], 'gov.bc.ca').replace('-data-gov-bc-ca', '.data').replace('-api-gov-bc-ca', '.api').replace('-apps-gov-bc-ca', '.apps') - else: - return host + services = get_services_by_ns(namespace) + routes = get_routes_by_ns(namespace) + + ns_svc = NamespaceService() + ns_attributes = ns_svc.get_namespace_attributes(namespace) + + res = [] + + try: + session = requests.Session() + session.headers.update({"Content-Type": "application/json"}) + service_payload = { + "services": services, + "routes": routes, + } + + dp = get_data_plane(ns_attributes) + rqst_url = app.config['data_planes'][dp]['kube-api'] + log.debug("[%s] - Initiating request to kube API" % (dp)) + + res = session.get(rqst_url + "/namespaces/%s/service-status" % namespace, + json=service_payload, + auth=(app.config['kubeApiCreds']['kubeApiUser'], app.config['kubeApiCreds']['kubeApiPass'])) + + log.debug("[%s] - The kube API responded with %s" % (dp, res.status_code)) + + if res.status_code != 200: + log.debug("[%s] - The kube API could not process the request" % (dp)) + raise Exception("[%s] - Failed to get services: %s" % (dp, str(res.text))) + + except HTTPException as ex: + log.error("Error getting status of services. %s" % str(ex)) + return make_response(jsonify({"error": "HTTP exception occurred", "details": str(ex)}), 500) + except requests.exceptions.RequestException as ex: + log.error("Request error: %s" % str(ex)) + return make_response(jsonify({"error": "Request exception occurred", "details": str(ex)}), 500) + except Exception as ex: + log.error("Unexpected error occurred: %s" % str(ex)) + traceback.print_exc() + return make_response(jsonify({"error": "Unexpected error occurred", "details": str(ex)}), 500) + finally: + session.close() + + # If the request was successful and a valid response was received, return the data. + return make_response(jsonify(res)) diff --git a/microservices/kubeApi/routers/routes.py b/microservices/kubeApi/routers/routes.py index 018b0e28..ed83dcbc 100644 --- a/microservices/kubeApi/routers/routes.py +++ b/microservices/kubeApi/routers/routes.py @@ -15,6 +15,10 @@ from fastapi.logger import logger from config import settings from typing import Optional +import socket +import requests +from urllib.parse import urlparse +import urllib3 router = APIRouter( prefix="", @@ -47,6 +51,11 @@ class BulkSyncRequest(BaseModel): # SSL Certificate serial number for custom domains sslCertificateSerialNumber: str + +class ServiceStatusRequest(BaseModel): + services: list + routes: list + @router.put("/namespaces/{namespace}/routes", status_code=201, dependencies=[Depends(verify_credentials)]) def add_routes(namespace: str, route: OCPRoute): @@ -157,6 +166,117 @@ def get_tls(namespace: str): logger.debug("[%s] returning %d certs" % (namespace, len(kong_certs))) return kong_certs +@router.get("/namespaces/{namespace}/service-status", status_code=200, dependencies=[Depends(verify_credentials)]) +def get_service_status(namespace: str, service_payload: ServiceStatusRequest): + logger.debug("[%s] get_service_status" % namespace) + + services = service_payload.services + routes = service_payload.routes + + response = [] + + for service in services: + url = build_url (service) + status = "UP" + reason = "" + + actual_host = None + host = None + for route in routes: + if route['service']['id'] == service['id'] and 'hosts' in route: + actual_host = route['hosts'][0] + if route['preserve_host']: + host = clean_host(actual_host) + + try: + addr = socket.gethostbyname(service['host']) + logger.info("Address = %s" % addr) + except: + status = "DOWN" + reason = "DNS" + + if status == "UP": + try: + headers = {} + if host is None or service['host'].endswith('.svc'): + r = requests.get(url, headers=headers, timeout=3.0) + status_code = r.status_code + else: + u = urlparse(url) + + if host is None: + headers['Host'] = u.hostname + else: + headers['Host'] = host + + logger.info("GET %-30s %s" % ("%s://%s" % (u.scheme, u.netloc), headers)) + + urllib3.disable_warnings() + if u.scheme == "https": + pool = urllib3.HTTPSConnectionPool( + "%s" % (u.netloc), + assert_hostname=host, + server_hostname=host, + cert_reqs='CERT_NONE', + ca_certs=certifi.where() + ) + else: + pool = urllib3.HTTPConnectionPool( + "%s" % (u.netloc) + ) + req = pool.urlopen( + "GET", + u.path, + headers={"Host": host}, + assert_same_host=False, + timeout=1.0, + retries=False + ) + + status_code = req.status + + logger.info("Result received!! %d" % status_code) + if status_code < 400: + status = "UP" + reason = "%d Response" % status_code + elif status_code == 401 or status_code == 403: + status = "UP" + reason = "AUTH %d" % status_code + else: + status = "DOWN" + reason = "%d Response" % status_code + except requests.exceptions.Timeout as ex: + status = "DOWN" + reason = "TIMEOUT" + except urllib3.exceptions.ConnectTimeoutError as ex: + status = "DOWN" + reason = "TIMEOUT" + except requests.exceptions.ConnectionError as ex: + logger.error("ConnError %s" % ex) + status = "DOWN" + reason = "CONNECTION" + except requests.exceptions.SSLError as ex: + status = "DOWN" + reason = "SSL" + except urllib3.exceptions.NewConnectionError as ex: + logger.error("NewConnError %s" % ex) + status = "DOWN" + reason = "CON_ERR" + except urllib3.exceptions.SSLError as ex: + logger.error(ex) + status = "DOWN" + reason = "SSL_URLLIB3" + except Exception as ex: + logger.error(ex) + traceback.print_exc(file=sys.stdout) + status = "DOWN" + reason = "UNKNOWN" + + logger.info("GET %-30s %s" % (url,reason)) + response.append({"name": service['name'], "upstream": url, "status": status, "reason": reason, "host": host, "env_host": actual_host}) + + return JSONResponse(content={"services_status": response}, status_code=200) + @router.post("/namespaces/{namespace}/routes/sync", status_code=200, dependencies=[Depends(verify_credentials)]) async def verify_and_create_routes(namespace: str, request: Request): @@ -276,4 +396,31 @@ def in_list_by_name(match, list): for item in list: if item['name'] == match['name']: return True - return False \ No newline at end of file + return False + +def build_url (s): + schema = default(s, "protocol", "http") + defaultPort = 80 + if schema == "https": + defaultPort = 443 + host = s['host'] + port = default(s, "port", defaultPort) + path = default(s, "path", "/") + if 'url' in s: + return s['url'] + else: + return "%s://%s:%d%s" % (schema, host, port, path) + +def default (s, key, val): + if key in s and s[key] is not None: + return s[key] + else: + return val + +def clean_host (host): + conf = app.config['hostTransformation'] + if conf['enabled'] is True: + conf = app.config['hostTransformation'] + return host.replace(conf['baseUrl'], 'gov.bc.ca').replace('-data-gov-bc-ca', '.data').replace('-api-gov-bc-ca', '.api').replace('-apps-gov-bc-ca', '.apps') + else: + return host From ac5eb1b21135f12a2a636281ed73aa6b379f3b49 Mon Sep 17 00:00:00 2001 From: James Elson Date: Wed, 12 Feb 2025 12:55:38 -0800 Subject: [PATCH 2/6] Fix response json --- microservices/gatewayApi/v2/routes/gw_status.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/microservices/gatewayApi/v2/routes/gw_status.py b/microservices/gatewayApi/v2/routes/gw_status.py index 6d099139..2f7da89b 100644 --- a/microservices/gatewayApi/v2/routes/gw_status.py +++ b/microservices/gatewayApi/v2/routes/gw_status.py @@ -64,5 +64,4 @@ def get_statuses(namespace: str) -> object: finally: session.close() - # If the request was successful and a valid response was received, return the data. - return make_response(jsonify(res)) + return make_response(jsonify(res.json())) From 3b2a7f23d738e470c03d106b979dd5a51c3ddca1 Mon Sep 17 00:00:00 2001 From: James Elson Date: Wed, 12 Feb 2025 13:09:44 -0800 Subject: [PATCH 3/6] Extract services_status from response --- microservices/gatewayApi/v2/routes/gw_status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microservices/gatewayApi/v2/routes/gw_status.py b/microservices/gatewayApi/v2/routes/gw_status.py index 2f7da89b..ba1ae0b8 100644 --- a/microservices/gatewayApi/v2/routes/gw_status.py +++ b/microservices/gatewayApi/v2/routes/gw_status.py @@ -64,4 +64,4 @@ def get_statuses(namespace: str) -> object: finally: session.close() - return make_response(jsonify(res.json())) + return make_response(jsonify(res.json().get("services_status", {}))) From c7da16ed15eb5597029f67308846375be235030f Mon Sep 17 00:00:00 2001 From: James Elson Date: Wed, 19 Feb 2025 14:54:00 -0800 Subject: [PATCH 4/6] Pass conf for clean_host function --- microservices/gatewayApi/v2/routes/gw_status.py | 1 + microservices/kubeApi/routers/routes.py | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/microservices/gatewayApi/v2/routes/gw_status.py b/microservices/gatewayApi/v2/routes/gw_status.py index ba1ae0b8..c42daf8a 100644 --- a/microservices/gatewayApi/v2/routes/gw_status.py +++ b/microservices/gatewayApi/v2/routes/gw_status.py @@ -35,6 +35,7 @@ def get_statuses(namespace: str) -> object: service_payload = { "services": services, "routes": routes, + "conf": app.config['hostTransformation'], } dp = get_data_plane(ns_attributes) diff --git a/microservices/kubeApi/routers/routes.py b/microservices/kubeApi/routers/routes.py index ed83dcbc..b53cade1 100644 --- a/microservices/kubeApi/routers/routes.py +++ b/microservices/kubeApi/routers/routes.py @@ -19,6 +19,7 @@ import requests from urllib.parse import urlparse import urllib3 +import certifi router = APIRouter( prefix="", @@ -55,6 +56,7 @@ class BulkSyncRequest(BaseModel): class ServiceStatusRequest(BaseModel): services: list routes: list + conf: dict @router.put("/namespaces/{namespace}/routes", status_code=201, dependencies=[Depends(verify_credentials)]) @@ -172,6 +174,7 @@ def get_service_status(namespace: str, service_payload: ServiceStatusRequest): services = service_payload.services routes = service_payload.routes + conf = service_payload.conf response = [] @@ -186,7 +189,7 @@ def get_service_status(namespace: str, service_payload: ServiceStatusRequest): if route['service']['id'] == service['id'] and 'hosts' in route: actual_host = route['hosts'][0] if route['preserve_host']: - host = clean_host(actual_host) + host = clean_host(actual_host, conf) try: addr = socket.gethostbyname(service['host']) @@ -417,10 +420,8 @@ def default (s, key, val): else: return val -def clean_host (host): - conf = app.config['hostTransformation'] +def clean_host (host, conf): if conf['enabled'] is True: - conf = app.config['hostTransformation'] return host.replace(conf['baseUrl'], 'gov.bc.ca').replace('-data-gov-bc-ca', '.data').replace('-api-gov-bc-ca', '.api').replace('-apps-gov-bc-ca', '.apps') else: return host From 4716c15a38c75ae04e4a37b2778551e6ca30adae Mon Sep 17 00:00:00 2001 From: James Elson Date: Thu, 20 Feb 2025 08:35:27 -0800 Subject: [PATCH 5/6] Add unit tests --- microservices/kubeApi/README.md | 6 +- .../tests/routers/test_service_status.py | 97 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 microservices/kubeApi/tests/routers/test_service_status.py diff --git a/microservices/kubeApi/README.md b/microservices/kubeApi/README.md index 79695cf3..1f500ad0 100644 --- a/microservices/kubeApi/README.md +++ b/microservices/kubeApi/README.md @@ -18,15 +18,15 @@ This API manages resources in Kubernetes environment. ```bash brew update brew install pyenv -pyenv install 3.9 -pyenv global 3.9 +pyenv install 3.11 +pyenv global 3.11 curl -sSL https://install.python-poetry.org | python3 - ``` #### Requirements ```bash -poetry env use 3.9 # (optional) +poetry env use 3.11 # (optional) poetry install ``` diff --git a/microservices/kubeApi/tests/routers/test_service_status.py b/microservices/kubeApi/tests/routers/test_service_status.py new file mode 100644 index 00000000..acc6596f --- /dev/null +++ b/microservices/kubeApi/tests/routers/test_service_status.py @@ -0,0 +1,97 @@ +from unittest import mock +import socket +import requests +from routers.routes import build_url, default, clean_host +import pytest + +@pytest.fixture +def service_payload(): + return { + "services": [{"id": "service-id", "name": "test-service", "host": "test-service.svc"}], + "routes": [{"service": {"id": "service-id"}, "hosts": ["test-service.svc"], "preserve_host": True}], + "conf": {"enabled": True, "baseUrl": "http://example.com"} + } + +def test_service_status_up(client, service_payload): + with mock.patch("socket.gethostbyname", return_value="1.1.1.1"): + with mock.patch("requests.get") as mock_get: + mock_get.return_value.status_code = 200 + + response = client.get("/namespaces/test-ns/service-status", json=service_payload) + assert response.status_code == 200 + assert response.json()['services_status'][0]['status'] == 'UP' + assert response.json()['services_status'][0]['reason'] == '200 Response' + +def test_service_status_dns_failure(client, service_payload): + with mock.patch("socket.gethostbyname", side_effect=socket.gaierror): + response = client.get("/namespaces/test-ns/service-status", json=service_payload) + assert response.status_code == 200 + assert response.json()['services_status'][0]['status'] == 'DOWN' + assert response.json()['services_status'][0]['reason'] == 'DNS' + +def test_service_status_timeout(client, service_payload): + with mock.patch("socket.gethostbyname", return_value="1.1.1.1"): + with mock.patch("requests.get", side_effect=requests.exceptions.Timeout): + response = client.get("/namespaces/test-ns/service-status", json=service_payload) + assert response.status_code == 200 + assert response.json()['services_status'][0]['status'] == 'DOWN' + assert response.json()['services_status'][0]['reason'] == 'TIMEOUT' + +def test_service_status_connection_error(client, service_payload): + with mock.patch("socket.gethostbyname", return_value="1.1.1.1"): + with mock.patch("requests.get", side_effect=requests.exceptions.ConnectionError): + response = client.get("/namespaces/test-ns/service-status", json=service_payload) + assert response.status_code == 200 + assert response.json()['services_status'][0]['status'] == 'DOWN' + assert response.json()['services_status'][0]['reason'] == 'CONNECTION' + +def test_service_status_custom_host(client, service_payload): + custom_host_payload = service_payload.copy() + custom_host_payload["routes"][0]["hosts"] = ["custom-host"] + + with mock.patch("socket.gethostbyname", return_value="1.1.1.1"): + with mock.patch("requests.get") as mock_get: + mock_get.return_value.status_code = 200 + response = client.get("/namespaces/test-ns/service-status", json=custom_host_payload) + assert response.status_code == 200 + assert response.json()['services_status'][0]['status'] == 'UP' + assert response.json()['services_status'][0]['reason'] == '200 Response' + assert response.json()['services_status'][0]['host'] == 'custom-host' + +def test_build_url(): + service = { + "host": "example.com", + "protocol": "http", + "port": 80, + "path": "/service" + } + + url = build_url(service) + assert url == "http://example.com:80/service" + + service_no_port = { "host": "example.com", "protocol": "http", "path": "/service" } + url = build_url(service_no_port) + assert url == "http://example.com:80/service" + + service_https = { "host": "example.com", "protocol": "https", "port": 443, "path": "/secure-service" } + url = build_url(service_https) + assert url == "https://example.com:443/secure-service" + +def test_default(): + service = {"host": "example.com", "protocol": "http"} + + assert default(service, "host", "default.com") == "example.com" + + assert default(service, "port", 8080) == 8080 + +def test_clean_host(): + conf_enabled = {"enabled": True, "baseUrl": "http://example.com"} + conf_disabled = {"enabled": False, "baseUrl": "http://example.com"} + + host = "test-data-gov-bc-ca.example.com" + + cleaned_host = clean_host(host, conf_enabled) + assert cleaned_host == "test.data.example.com" + + cleaned_host = clean_host(host, conf_disabled) + assert cleaned_host == "test-data-gov-bc-ca.example.com" From d068ab9c1c406c5861f0f8e223e970a70d9ad78d Mon Sep 17 00:00:00 2001 From: Elson9 Date: Mon, 24 Feb 2025 16:51:01 -0800 Subject: [PATCH 6/6] Potential fix for code scanning alert no. 39: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- microservices/gatewayApi/v2/routes/gw_status.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/microservices/gatewayApi/v2/routes/gw_status.py b/microservices/gatewayApi/v2/routes/gw_status.py index c42daf8a..194746eb 100644 --- a/microservices/gatewayApi/v2/routes/gw_status.py +++ b/microservices/gatewayApi/v2/routes/gw_status.py @@ -54,14 +54,14 @@ def get_statuses(namespace: str) -> object: except HTTPException as ex: log.error("Error getting status of services. %s" % str(ex)) - return make_response(jsonify({"error": "HTTP exception occurred", "details": str(ex)}), 500) + return make_response(jsonify({"error": "HTTP exception occurred"}), 500) except requests.exceptions.RequestException as ex: log.error("Request error: %s" % str(ex)) - return make_response(jsonify({"error": "Request exception occurred", "details": str(ex)}), 500) + return make_response(jsonify({"error": "Request exception occurred"}), 500) except Exception as ex: log.error("Unexpected error occurred: %s" % str(ex)) traceback.print_exc() - return make_response(jsonify({"error": "Unexpected error occurred", "details": str(ex)}), 500) + return make_response(jsonify({"error": "Unexpected error occurred"}), 500) finally: session.close()