diff --git a/services/healthcheck/.dockerignore b/services/healthcheck/.dockerignore deleted file mode 100644 index fc3df9e..0000000 --- a/services/healthcheck/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -**/*_test.py diff --git a/services/healthcheck/.python-version b/services/healthcheck/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/services/healthcheck/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/services/healthcheck/Dockerfile b/services/healthcheck/Dockerfile deleted file mode 100644 index 294dd68..0000000 --- a/services/healthcheck/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.12 AS base - -WORKDIR /app - -COPY requirements.txt . - -RUN pip install -r requirements.txt - -FROM base AS app - -ENV HEALTHCHECK_CONFIG_PATH "/app/config.json" - -COPY healthcheck ./healthcheck - -CMD python -m uvicorn --host "0.0.0.0" healthcheck.main:app diff --git a/services/healthcheck/README.md b/services/healthcheck/README.md deleted file mode 100644 index 2ec9be8..0000000 --- a/services/healthcheck/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Healthcheck reporter - -Periodically checks if resources are reacheable and reports via a configurable webhook. - -## Configuration - -A `config.json` file should be provided and follow the schema outlined in `use_cases.Configuration`: - -```json -{ - "endpoints": { - "service-a": "https://service-a.com", - "service-b": "http://service-b:8080", - ... - }, - "webhook_url": "https://my-webhook.com/", - "check_interval": 3600 -} -``` - -Every `check_interval` seconds, the application will attempt to reach each of the services and post a message summarizing the results to `webhook_url`. diff --git a/services/healthcheck/build.sh b/services/healthcheck/build.sh deleted file mode 100755 index e5c5272..0000000 --- a/services/healthcheck/build.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/bash - -source ./constants.sh - -podman build . -t "$APP_IMAGE_NAME":"$IMAGE_VERSION" diff --git a/services/healthcheck/constants.sh b/services/healthcheck/constants.sh deleted file mode 100644 index 4c1a8ff..0000000 --- a/services/healthcheck/constants.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -export APP_NAME="healthcheck" -export APP_CONTAINER_NAME=$APP_NAME-app -export APP_IMAGE_NAME=$CONTAINER_NAME_PREFIX-$APP_CONTAINER_NAME diff --git a/services/healthcheck/healthcheck/__init__.py b/services/healthcheck/healthcheck/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/healthcheck/healthcheck/conftest.py b/services/healthcheck/healthcheck/conftest.py deleted file mode 100644 index 63d9ed6..0000000 --- a/services/healthcheck/healthcheck/conftest.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -Shared fixtures. -""" - -import json - -import pytest - - -@pytest.fixture -def anyio_backend(): - """Sets the default anyio backend.""" - return "asyncio" - - -@pytest.fixture(name="mock_configuration") -def f_mock_configuration(): - """Sample configuration""" - return { - "endpoints": {"test-service": "http://test.local"}, - "webhook_url": "http://webhook.local", - "check_interval": 0.1, - } - - -@pytest.fixture(name="set_up_mock_configuration") -def f_set_up_mock_configuration(monkeypatch, tmp_path, mock_configuration): - """ - Initializes a file with the sample configuration data and sets the - HEALTHCHECK_CONFIG_PATH variable to point to it. - """ - - def _fixture(): - config_path = tmp_path / "config.json" - - config_path.write_text(json.dumps(mock_configuration)) - - monkeypatch.setenv("HEALTHCHECK_CONFIG_PATH", str(config_path)) - - return config_path - - return _fixture diff --git a/services/healthcheck/healthcheck/main.py b/services/healthcheck/healthcheck/main.py deleted file mode 100644 index e82beef..0000000 --- a/services/healthcheck/healthcheck/main.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Healthcheck reporting service. - -This periodically checks if certain URLs respond and -reports on those responses. -""" - -import asyncio -import contextlib -import logging - -import fastapi - -from healthcheck.tasks import report_on_statuses -from healthcheck.use_cases import check_all_statuses, load_configuration - -logging.basicConfig(level=logging.INFO) - -logger = logging.getLogger(__name__) -background_tasks = set() - - -@contextlib.asynccontextmanager -async def lifespan(_): - """Starts and stops asynchronous tasks on application lifecycle.""" - status_checks = asyncio.create_task(report_on_statuses()) - background_tasks.add(status_checks) - logger.info("Started reporting loop.") - yield - - for task in background_tasks: - task.cancel() - - -app = fastapi.FastAPI(lifespan=lifespan) - - -@app.get("/") -def alive(): - """Is the application alive?""" - return 200 - - -@app.get("/config") -def configuration(): - """Check available configuration""" - return fastapi.responses.JSONResponse(load_configuration().model_dump()) - - -@app.get("/status") -def check_status(): - """Checks endpoints respond""" - config = load_configuration() - - return check_all_statuses(config.endpoints) diff --git a/services/healthcheck/healthcheck/main_test.py b/services/healthcheck/healthcheck/main_test.py deleted file mode 100644 index 69cbdd6..0000000 --- a/services/healthcheck/healthcheck/main_test.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Test coverage for endpoints and use cases. -""" - -import pytest -from fastapi.testclient import TestClient - -from healthcheck.main import app - - -@pytest.fixture(name="client") -def f_client(): - """Test HTTP client.""" - return TestClient(app) - - -def test_alive_check_returns_200(client): - """Alive check returns 200.""" - response = client.get("/") - - assert response.status_code == 200 - - -def test_check_configuration_returns_config_and_200( - client, set_up_mock_configuration, mock_configuration -): - """Check configuration returns the loaded configuration.""" - set_up_mock_configuration() - - response = client.get("/config") - - assert response.json() == mock_configuration - - -def test_check_status_returns_status_summary( - httpx_mock, client, set_up_mock_configuration -): - """ - Check status endpoint checks all configured endpoints and reports on - availability. - """ - set_up_mock_configuration() - - httpx_mock.add_response(url="http://test.local", status_code=200) - - response = client.get("/status") - - assert response.status_code == 200 - - response_body = response.json() - - assert response_body == {"test-service": True} diff --git a/services/healthcheck/healthcheck/tasks.py b/services/healthcheck/healthcheck/tasks.py deleted file mode 100644 index 9f8f2bd..0000000 --- a/services/healthcheck/healthcheck/tasks.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Defines asynchronous tasks loaded on startup. -""" - -import logging -import time -import typing - -from healthcheck.use_cases import check_all_statuses, load_configuration, post_message - -logger = logging.getLogger(__name__) - - -async def report_on_statuses(*, max_iterations: typing.Optional[int] = None): - """ - Reports on all registered services. - """ - - config = load_configuration() - - iterations = 0 - - while max_iterations is None or iterations < max_iterations: - statuses = check_all_statuses(config.endpoints) - - message_lines = [] - - for service, status in statuses.items(): - if status: - message_lines.append(f"✅ {service} is healthy.") - else: - message_lines.append(f"🔥 {service} is not responding normally.") - - message = "\n".join(message_lines) - - logger.info(message) - - post_message(config.webhook_url, message) - iterations += 1 - - time.sleep(config.check_interval) diff --git a/services/healthcheck/healthcheck/tasks_test.py b/services/healthcheck/healthcheck/tasks_test.py deleted file mode 100644 index 5076245..0000000 --- a/services/healthcheck/healthcheck/tasks_test.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Asynchronous task tests. -""" - -import json - -import pytest - -from healthcheck.tasks import report_on_statuses - -pytestmark = pytest.mark.anyio - - -async def test_report_on_statuses_pings_registered_endpoints( - httpx_mock, mock_configuration, set_up_mock_configuration -): - """Each run pings each specified service.""" - mock_url = mock_configuration["endpoints"]["test-service"] - - set_up_mock_configuration() - - httpx_mock.add_response(url=mock_url) - httpx_mock.add_response(url=mock_configuration["webhook_url"]) - - await report_on_statuses(max_iterations=1) - - requests_captured = httpx_mock.get_requests() - - get_requests = tuple( - request for request in requests_captured if request.method == "GET" - ) - - assert len(get_requests) == 1 - assert str(get_requests[0].url) == mock_url - - -async def test_report_on_statuses_posts_message_to_the_webhook_url( - httpx_mock, mock_configuration, set_up_mock_configuration -): - """Each run posts a message to the webhook for reporting.""" - mock_url = mock_configuration["endpoints"]["test-service"] - - set_up_mock_configuration() - - httpx_mock.add_response(url=mock_url) - httpx_mock.add_response(url=mock_configuration["webhook_url"]) - - await report_on_statuses(max_iterations=1) - - requests_captured = httpx_mock.get_requests() - - post_requests = tuple( - request for request in requests_captured if request.method == "POST" - ) - - # Only one webhook request is made. - assert len(post_requests) == 1 - - webhook_post = post_requests[0] - - # The request goes to the webhook URL. - assert str(webhook_post.url) == mock_configuration["webhook_url"] - - -@pytest.mark.parametrize( - "status_code, expected", - [[200, "is healthy"], [400, "is not responding normally"]], - ids=["healthy", "unhealthy"], -) -async def test_report_on_statuses_posts_message_describing_service_status( - status_code, expected, httpx_mock, mock_configuration, set_up_mock_configuration -): - """Each message describes whether the service is healthy or not.""" - mock_url = mock_configuration["endpoints"]["test-service"] - - set_up_mock_configuration() - - httpx_mock.add_response(url=mock_url, status_code=status_code) - httpx_mock.add_response(url=mock_configuration["webhook_url"]) - - await report_on_statuses(max_iterations=1) - - requests_captured = httpx_mock.get_requests() - - post_requests = tuple( - request for request in requests_captured if request.method == "POST" - ) - - webhook_post = post_requests[0] - - posted_message = json.loads(webhook_post.content) - - assert expected in posted_message["content"] - - -@pytest.mark.parametrize("iterations", [1, 10]) -async def test_report_on_statuses_runs_at_most_n_times_if_max_iterations_specified( - iterations, httpx_mock, mock_configuration, set_up_mock_configuration -): - """Iterations can be capped to a certain count.""" - mock_url = mock_configuration["endpoints"]["test-service"] - - set_up_mock_configuration() - - httpx_mock.add_response(url=mock_url) - httpx_mock.add_response(url=mock_configuration["webhook_url"]) - - await report_on_statuses(max_iterations=iterations) - - requests_captured = httpx_mock.get_requests() - - # Each iteration pings once, posts once. - assert len(requests_captured) == 2 * iterations diff --git a/services/healthcheck/healthcheck/use_cases.py b/services/healthcheck/healthcheck/use_cases.py deleted file mode 100644 index f5eaef6..0000000 --- a/services/healthcheck/healthcheck/use_cases.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Business logic for endpoints and tasks. -""" - -import functools -import json -import logging -import os -import pathlib - -import httpx -import pydantic - -logger = logging.getLogger(__name__) - - -class Configuration(pydantic.BaseModel): - """Service configuration""" - - endpoints: dict[str, str] - webhook_url: str - check_interval: float - - -@functools.cache -def load_configuration() -> Configuration: - """ - Loads configuration from disk. - - If the HEALTHCHECK_CONFIG_PATH env variable is not set, raises. - If the configuration file is not valid json, raises. - If the configuration data doesn't satisfy the Configuration type, raises. - """ - raw_config_path = os.getenv("HEALTHCHECK_CONFIG_PATH") - - if not raw_config_path: - raise RuntimeError( - "No configuration path provided. HEALTHCHECK_CONFIG_PATH must be set." - ) - - config_path = pathlib.Path(raw_config_path) - - if not config_path.exists(): - raise RuntimeError(f"Configuration file does not exist at {config_path}") - - with open(config_path, "r", encoding="utf8") as config_file: - config_raw = config_file.read() - - try: - config = json.loads(config_raw) - except Exception as e: - raise RuntimeError( - "Failed to parse configuration file at {config_path}: {str(e)}" - ) from e - - return Configuration(**config) - - -def check_all_statuses(endpoints: dict[str, str]) -> dict[str, bool]: - """ - Pings all the specified endpoint and produces a mapping describing - whether the target responded with a "OK-ish" status (i.e. 2XX). - - Exceptions raised while requesting are logged and reported as failures - to check. - """ - - status_summary = {} - - for service_name, service_url in endpoints.items(): - try: - response = httpx.get(service_url) - response.raise_for_status() - except Exception: # pylint: disable=broad-except - logger.exception( - "Failed to check health of %s: (%s)", service_name, service_url - ) - status_summary[service_name] = False - else: - status_summary[service_name] = True - - return status_summary - - -def post_message(webhook_url: str, message: str): - """ - Posts a message to a Discord webhook URL. - - See https://discord.com/developers/docs/resources/webhook#execute-webhook for - payload schema. - """ - - payload = {"content": message} - - response = httpx.post(webhook_url, json=payload) - - response.raise_for_status() diff --git a/services/healthcheck/healthcheck/use_cases_test.py b/services/healthcheck/healthcheck/use_cases_test.py deleted file mode 100644 index 3bff39d..0000000 --- a/services/healthcheck/healthcheck/use_cases_test.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -Business logic tests. -""" - -from healthcheck.use_cases import load_configuration - - -def test_load_configuration(set_up_mock_configuration, mock_configuration): - """Checks that configuration can be loaded.""" - set_up_mock_configuration() - - configuration = load_configuration() - - assert configuration.model_dump() == mock_configuration diff --git a/services/healthcheck/pyproject.toml b/services/healthcheck/pyproject.toml deleted file mode 100644 index 5a8db28..0000000 --- a/services/healthcheck/pyproject.toml +++ /dev/null @@ -1,34 +0,0 @@ -[project] -name = "healthcheck" -version = "0.0.0" -requires-python = ">= 3.12" -dependencies = [ - "fastapi", - "httpx", - "pydantic", - "uvicorn[standard]", -] - -[project.optional-dependencies] -dev = [ - "anyio", - "black", - "pylint", - "httpx", - "pytest", - "pytest-httpx", - "isort", -] - -[tool.setuptools] -packages = ["healthcheck"] - -[tool.pytest.ini_options] -pythonpath=[ - ".", - "./healthcheck", -] -python_files=[ - "*_test.py" -] - diff --git a/services/healthcheck/requirements.txt b/services/healthcheck/requirements.txt deleted file mode 100644 index f6ab74d..0000000 --- a/services/healthcheck/requirements.txt +++ /dev/null @@ -1,58 +0,0 @@ -annotated-types==0.6.0 - # via pydantic -anyio==4.3.0 - # via - # httpx - # starlette - # watchfiles -certifi==2024.2.2 - # via - # httpcore - # httpx -click==8.1.7 - # via uvicorn -fastapi==0.110.0 - # via healthcheck (pyproject.toml) -h11==0.14.0 - # via - # httpcore - # uvicorn -httpcore==1.0.4 - # via httpx -httptools==0.6.1 - # via uvicorn -httpx==0.27.0 - # via healthcheck (pyproject.toml) -idna==3.6 - # via - # anyio - # httpx -pydantic==2.6.3 - # via - # fastapi - # healthcheck (pyproject.toml) -pydantic-core==2.16.3 - # via pydantic -python-dotenv==1.0.1 - # via uvicorn -pyyaml==6.0.1 - # via uvicorn -sniffio==1.3.1 - # via - # anyio - # httpx -starlette==0.36.3 - # via fastapi -typing-extensions==4.10.0 - # via - # fastapi - # pydantic - # pydantic-core -uvicorn[standard]==0.27.1 - # via healthcheck (pyproject.toml) -uvloop==0.19.0 - # via uvicorn -watchfiles==0.21.0 - # via uvicorn -websockets==12.0 - # via uvicorn diff --git a/services/healthcheck/requirements_dev.txt b/services/healthcheck/requirements_dev.txt deleted file mode 100644 index fe4a2be..0000000 --- a/services/healthcheck/requirements_dev.txt +++ /dev/null @@ -1,134 +0,0 @@ -annotated-types==0.6.0 - # via - # -c requirements.txt - # pydantic -anyio==4.3.0 - # via - # -c requirements.txt - # healthcheck (pyproject.toml) - # httpx - # starlette - # watchfiles -astroid==3.1.0 - # via pylint -black==24.2.0 - # via healthcheck (pyproject.toml) -certifi==2024.2.2 - # via - # -c requirements.txt - # httpcore - # httpx -click==8.1.7 - # via - # -c requirements.txt - # black - # uvicorn -dill==0.3.8 - # via pylint -fastapi==0.110.0 - # via - # -c requirements.txt - # healthcheck (pyproject.toml) -h11==0.14.0 - # via - # -c requirements.txt - # httpcore - # uvicorn -httpcore==1.0.4 - # via - # -c requirements.txt - # httpx -httptools==0.6.1 - # via - # -c requirements.txt - # uvicorn -httpx==0.27.0 - # via - # -c requirements.txt - # healthcheck (pyproject.toml) - # pytest-httpx -idna==3.6 - # via - # -c requirements.txt - # anyio - # httpx -iniconfig==2.0.0 - # via pytest -isort==5.13.2 - # via - # healthcheck (pyproject.toml) - # pylint -mccabe==0.7.0 - # via pylint -mypy-extensions==1.0.0 - # via black -packaging==23.2 - # via - # black - # pytest -pathspec==0.12.1 - # via black -platformdirs==4.2.0 - # via - # black - # pylint -pluggy==1.4.0 - # via pytest -pydantic==2.6.3 - # via - # -c requirements.txt - # fastapi - # healthcheck (pyproject.toml) -pydantic-core==2.16.3 - # via - # -c requirements.txt - # pydantic -pylint==3.1.0 - # via healthcheck (pyproject.toml) -pytest==8.0.2 - # via - # healthcheck (pyproject.toml) - # pytest-httpx -pytest-httpx==0.30.0 - # via healthcheck (pyproject.toml) -python-dotenv==1.0.1 - # via - # -c requirements.txt - # uvicorn -pyyaml==6.0.1 - # via - # -c requirements.txt - # uvicorn -sniffio==1.3.1 - # via - # -c requirements.txt - # anyio - # httpx -starlette==0.36.3 - # via - # -c requirements.txt - # fastapi -tomlkit==0.12.4 - # via pylint -typing-extensions==4.10.0 - # via - # -c requirements.txt - # fastapi - # pydantic - # pydantic-core -uvicorn[standard]==0.27.1 - # via - # -c requirements.txt - # healthcheck (pyproject.toml) -uvloop==0.19.0 - # via - # -c requirements.txt - # uvicorn -watchfiles==0.21.0 - # via - # -c requirements.txt - # uvicorn -websockets==12.0 - # via - # -c requirements.txt - # uvicorn diff --git a/services/healthcheck/script/bootstrap.sh b/services/healthcheck/script/bootstrap.sh deleted file mode 100755 index 8066e30..0000000 --- a/services/healthcheck/script/bootstrap.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/bash - -python -m venv .venv - -. .venv/bin/activate - -pip install -U pip~=24.0 pip-tools~=7.3.0 - -pip-sync requirements.txt requirements_dev.txt diff --git a/services/healthcheck/script/lock-deps.sh b/services/healthcheck/script/lock-deps.sh deleted file mode 100755 index 3c22c01..0000000 --- a/services/healthcheck/script/lock-deps.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/bash - -PYTHON=.venv/bin/python - -$PYTHON -m piptools compile -o requirements.txt pyproject.toml --no-header \ - && $PYTHON -m piptools compile -o requirements_dev.txt --no-header --extra dev --constraint requirements.txt pyproject.toml diff --git a/services/healthcheck/start.sh b/services/healthcheck/start.sh deleted file mode 100755 index 4b8a6be..0000000 --- a/services/healthcheck/start.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/bash - -source ./constants.sh - -podman run \ - --detach \ - --pod services \ - -v ./config.json:/app/config.json \ - --name "$APP_NAME" \ - "$APP_IMAGE_NAME":"$IMAGE_VERSION" diff --git a/services/healthcheck/stop.sh b/services/healthcheck/stop.sh deleted file mode 100644 index 818f1cd..0000000 --- a/services/healthcheck/stop.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/bash - -source ./constants.sh - -podman rm -f $APP_NAME