diff --git a/.github/workflows/test_fastpath.yml b/.github/workflows/test_fastpath.yml index c03e5a3f..7e039e3d 100644 --- a/.github/workflows/test_fastpath.yml +++ b/.github/workflows/test_fastpath.yml @@ -1,5 +1,8 @@ name: test fastpath -on: push +on: + push: + branches: + - master jobs: test: diff --git a/.github/workflows/test_legacy_ooniapi.yml b/.github/workflows/test_legacy_ooniapi.yml index 2b551738..5b1c8db7 100644 --- a/.github/workflows/test_legacy_ooniapi.yml +++ b/.github/workflows/test_legacy_ooniapi.yml @@ -1,6 +1,8 @@ name: test legacy/ooniapi on: push: + branches: + - master workflow_dispatch: inputs: debug_enabled: diff --git a/ooniapi/common/src/common/config.py b/ooniapi/common/src/common/config.py index 245ab572..e275444c 100644 --- a/ooniapi/common/src/common/config.py +++ b/ooniapi/common/src/common/config.py @@ -17,3 +17,4 @@ class Settings(BaseSettings): statsd_port: int = 8125 statsd_prefix: str = "ooniapi" jwt_encryption_key: str = "CHANGEME" + prometheus_metrics_password: str = "CHANGEME" diff --git a/ooniapi/common/src/common/metrics.py b/ooniapi/common/src/common/metrics.py new file mode 100644 index 00000000..4ee2097f --- /dev/null +++ b/ooniapi/common/src/common/metrics.py @@ -0,0 +1,42 @@ +import secrets + +from typing import Annotated + +from fastapi import FastAPI, Response, Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +from .dependencies import get_settings +from prometheus_client import ( + CONTENT_TYPE_LATEST, + CollectorRegistry, + generate_latest, +) + +security = HTTPBasic() + + +def mount_metrics(app: FastAPI, registry: CollectorRegistry): + def metrics( + credentials: Annotated[HTTPBasicCredentials, Depends(security)], + settings=Depends(get_settings), + ): + is_correct_username = secrets.compare_digest( + credentials.username.encode("utf8"), b"prom" + ) + is_correct_password = secrets.compare_digest( + credentials.password.encode("utf8"), + settings.prometheus_metrics_password.encode("utf-8"), + ) + if not (is_correct_username and is_correct_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + + resp = Response(content=generate_latest(registry)) + resp.headers["Content-Type"] = CONTENT_TYPE_LATEST + return resp + + endpoint = "/metrics" + app.get(endpoint, include_in_schema=True, tags=None)(metrics) diff --git a/ooniapi/services/oonirun/alembic/versions/981d92cf8790_init_tables.py b/ooniapi/services/oonirun/alembic/versions/981d92cf8790_init_tables.py index fb98453d..147afeb4 100644 --- a/ooniapi/services/oonirun/alembic/versions/981d92cf8790_init_tables.py +++ b/ooniapi/services/oonirun/alembic/versions/981d92cf8790_init_tables.py @@ -83,5 +83,5 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_table("oonirun") op.drop_table("oonirun_nettest") + op.drop_table("oonirun") \ No newline at end of file diff --git a/ooniapi/services/oonirun/pyproject.toml b/ooniapi/services/oonirun/pyproject.toml index 90f4399c..93f24f2c 100644 --- a/ooniapi/services/oonirun/pyproject.toml +++ b/ooniapi/services/oonirun/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "httpx ~= 0.26.0", "pyjwt ~= 2.8.0", "alembic ~= 1.13.1", + "prometheus-fastapi-instrumentator ~= 6.1.0", + "prometheus-client", ] readme = "README.md" @@ -56,7 +58,7 @@ packages = ["src/oonirun"] artifacts = ["BUILD_LABEL"] [tool.hatch.envs.default] -dependencies = ["pytest", "pytest-cov", "click", "black", "pytest-postgresql"] +dependencies = ["pytest", "pytest-cov", "click", "black", "pytest-postgresql", "pytest-asyncio"] path = ".venv/" [tool.hatch.envs.default.scripts] diff --git a/ooniapi/services/oonirun/src/oonirun/__about__.py b/ooniapi/services/oonirun/src/oonirun/__about__.py index 342f1d78..d04bdc97 100644 --- a/ooniapi/services/oonirun/src/oonirun/__about__.py +++ b/ooniapi/services/oonirun/src/oonirun/__about__.py @@ -1 +1 @@ -VERSION = "0.5.0dev1" +VERSION = "0.5.0rc0" diff --git a/ooniapi/services/oonirun/src/oonirun/main.py b/ooniapi/services/oonirun/src/oonirun/main.py index 485c6335..ef4ef96e 100644 --- a/ooniapi/services/oonirun/src/oonirun/main.py +++ b/ooniapi/services/oonirun/src/oonirun/main.py @@ -1,15 +1,20 @@ -from functools import lru_cache -from fastapi import FastAPI +import logging +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from prometheus_fastapi_instrumentator import Instrumentator + +from . import models from .routers import oonirun -from .dependencies import get_settings +from .dependencies import get_postgresql_session, get_settings from .common.version import get_build_label, get_pkg_version -from fastapi.middleware.cors import CORSMiddleware +from .common.metrics import mount_metrics -from contextlib import asynccontextmanager - -import logging pkg_name = "oonirun" @@ -21,11 +26,16 @@ async def lifespan(app: FastAPI): settings = get_settings() logging.basicConfig(level=getattr(logging, settings.log_level.upper())) + mount_metrics(app, instrumentor.registry) yield app = FastAPI(lifespan=lifespan) +instrumentor = Instrumentator().instrument( + app, metric_namespace="ooniapi", metric_subsystem="oonirun" +) + # TODO: temporarily enable all origins = ["*"] app.add_middleware( @@ -44,9 +54,41 @@ async def version(): return {"version": pkg_version, "build_label": build_label} +class HealthStatus(BaseModel): + status: str + errors: list[str] = [] + version: str + build_label: str + + @app.get("/health") -async def health(): - return {"status": "ok", "version": pkg_version, "build_label": build_label} +async def health( + settings=Depends(get_settings), + db=Depends(get_postgresql_session), +): + errors = [] + try: + db.query(models.OONIRunLink).limit(1).all() + except Exception as exc: + print(exc) + errors.append("db_error") + + if settings.jwt_encryption_key == "CHANGEME": + errors.append("bad_jwt_secret") + + if settings.prometheus_metrics_password == "CHANGEME": + errors.append("bad_prometheus_password") + + status = "ok" + if len(errors) > 0: + status = "fail" + + return { + "status": status, + "errors": errors, + "version": pkg_version, + "build_label": build_label, + } @app.get("/") diff --git a/ooniapi/services/oonirun/src/oonirun/routers/oonirun.py b/ooniapi/services/oonirun/src/oonirun/routers/oonirun.py index 2b900733..d216dc88 100644 --- a/ooniapi/services/oonirun/src/oonirun/routers/oonirun.py +++ b/ooniapi/services/oonirun/src/oonirun/routers/oonirun.py @@ -5,9 +5,7 @@ """ from datetime import datetime, timedelta, timezone, date -from os import urandom -from sys import byteorder -from typing import Dict, Any, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import logging import sqlalchemy as sa @@ -17,7 +15,6 @@ from pydantic import BaseModel as PydandicBaseModel from typing_extensions import Annotated - from .. import models from ..common.dependencies import get_settings, role_required @@ -28,7 +25,6 @@ ) from ..dependencies import get_postgresql_session - ISO_FORMAT_DATETIME = "%Y-%m-%dT%H:%M:%S.%fZ" ISO_FORMAT_DATE = "%Y-%m-%d" diff --git a/ooniapi/services/oonirun/tests/conftest.py b/ooniapi/services/oonirun/tests/conftest.py index 896ce74d..3bf337e3 100644 --- a/ooniapi/services/oonirun/tests/conftest.py +++ b/ooniapi/services/oonirun/tests/conftest.py @@ -35,10 +35,22 @@ def alembic_migration(postgresql): yield db_url +@pytest.fixture +def client_with_bad_settings(): + app.dependency_overrides[get_settings] = make_override_get_settings( + postgresql_url="postgresql://bad:bad@localhost/bad" + ) + + client = TestClient(app) + yield client + + @pytest.fixture def client(alembic_migration): app.dependency_overrides[get_settings] = make_override_get_settings( - postgresql_url=alembic_migration + postgresql_url=alembic_migration, + jwt_encryption_key="super_secure", + prometheus_metrics_password="super_secure", ) client = TestClient(app) @@ -46,9 +58,7 @@ def client(alembic_migration): def create_jwt(payload: dict) -> str: - settings = Settings() - key = settings.jwt_encryption_key - return jwt.encode(payload, key, algorithm="HS256") + return jwt.encode(payload, "super_secure", algorithm="HS256") def create_session_token(account_id: str, role: str) -> str: diff --git a/ooniapi/services/oonirun/tests/test_database.py b/ooniapi/services/oonirun/tests/test_database.py new file mode 100644 index 00000000..0606a441 --- /dev/null +++ b/ooniapi/services/oonirun/tests/test_database.py @@ -0,0 +1,176 @@ +from copy import deepcopy +from datetime import datetime +import pathlib +from oonirun.routers.oonirun import utcnow_seconds +import pytest + +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker +from oonirun import models +from oonirun.dependencies import get_postgresql_session +from sqlalchemy import create_engine + +SAMPLE_OONIRUN = { + "name": "", + "name_intl": {}, + "description": "integ-test description in English", + "description_intl": { + "es": "integ-test descripciĆ³n en espaƱol", + }, + "short_description": "integ-test short description in English", + "short_description_intl": { + "it": "integ-test descrizione breve in italiano", + }, + "icon": "myicon", + "author": "integ-test author", + "nettests": [ + { + "inputs": [ + "https://example.com/", + "https://ooni.org/", + ], + "options": { + "HTTP3Enabled": True, + }, + "backend_options": {}, + "is_background_run_enabled_default": False, + "is_manual_run_enabled_default": False, + "test_name": "web_connectivity", + }, + { + "inputs": [], + "options": {}, + "backend_options": {}, + "is_background_run_enabled_default": False, + "is_manual_run_enabled_default": False, + "test_name": "dnscheck", + }, + ], +} + + +def config_alembic(db_url): + from alembic.config import Config + + migrations_path = (pathlib.Path(__file__).parent.parent / "alembic").resolve() + + alembic_cfg = Config() + alembic_cfg.set_main_option("script_location", str(migrations_path)) + alembic_cfg.set_main_option("sqlalchemy.url", db_url) + return alembic_cfg + + +def upgrade_to_head(db_url): + from alembic import command + + command.upgrade(config_alembic(db_url), "head") + + +def get_db(pg_url): + engine = create_engine(pg_url) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + return SessionLocal() + + +def test_downgrade(postgresql): + from alembic import command + + db_url = f"postgresql://{postgresql.info.user}:@{postgresql.info.host}:{postgresql.info.port}/{postgresql.info.dbname}" + + command.upgrade(config_alembic(db_url), "head") + command.downgrade(config_alembic(db_url), "-1") + + +def test_upgrade_to_head(postgresql): + db_url = f"postgresql://{postgresql.info.user}:@{postgresql.info.host}:{postgresql.info.port}/{postgresql.info.dbname}" + upgrade_to_head(db_url) + db = get_db(db_url) + + run_link = deepcopy(SAMPLE_OONIRUN) + nettests = run_link.pop("nettests") + + new_row = db.query(models.OONIRunLink).first() + + db_runlink = models.OONIRunLink( + **run_link, + oonirun_link_id="000000000", + date_created=utcnow_seconds(), + date_updated=utcnow_seconds(), + expiration_date=utcnow_seconds(), + creator_account_id="000000000", + ) + db_runlink.nettests = [ + models.OONIRunLinkNettest( + **nettests[0], + revision=1, + nettest_index=0, + date_created=utcnow_seconds(), + ), + models.OONIRunLinkNettest( + **nettests[1], + revision=1, + nettest_index=1, + date_created=utcnow_seconds(), + ), + models.OONIRunLinkNettest( + **nettests[1], + revision=2, + nettest_index=0, + date_created=utcnow_seconds(), + ), + models.OONIRunLinkNettest( + **nettests[1], + revision=3, + nettest_index=0, + date_created=utcnow_seconds(), + ), + ] + db.add(db_runlink) + db.commit() + + new_row = db.query(models.OONIRunLink).first() + assert new_row + assert new_row.nettests[0].revision == 3 + + db.close() + + with pytest.raises(sa.exc.StatementError): + db_runlink = models.OONIRunLink( + **run_link, + oonirun_link_id="000000000", + date_created="NOT A DATE", + date_updated=utcnow_seconds(), + expiration_date=utcnow_seconds(), + creator_account_id="000000000", + ) + db.add(db_runlink) + db.commit() + db.rollback() + + with pytest.raises(sa.exc.StatementError): + naive_datetime = datetime.now() + db_runlink = models.OONIRunLink( + **run_link, + oonirun_link_id="000000000", + date_created=naive_datetime, + date_updated=utcnow_seconds(), + expiration_date=utcnow_seconds(), + creator_account_id="000000000", + ) + db.add(db_runlink) + db.commit() + db.rollback() + + with pytest.raises(sa.exc.StatementError): + db_runlink = models.OONIRunLink( + **run_link, + oonirun_link_id="000000000", + date_created=None, + date_updated=utcnow_seconds(), + expiration_date=utcnow_seconds(), + creator_account_id="000000000", + ) + db.add(db_runlink) + db.commit() + db.rollback() diff --git a/ooniapi/services/oonirun/tests/test_main.py b/ooniapi/services/oonirun/tests/test_main.py new file mode 100644 index 00000000..cb70a9c2 --- /dev/null +++ b/ooniapi/services/oonirun/tests/test_main.py @@ -0,0 +1,35 @@ +import pytest + +import httpx +from fastapi.testclient import TestClient +from oonirun.main import lifespan, app + + +def test_health_good(client): + r = client.get("health") + j = r.json() + assert j["status"] == "ok", j + assert len(j["errors"]) == 0, j + + +def test_health_bad(client_with_bad_settings): + r = client_with_bad_settings.get("health") + j = r.json() + assert j["status"] == "fail", j + assert len(j["errors"]) > 0, j + + +def test_metrics(client): + r = client.get("/metrics") + + +@pytest.mark.asyncio +async def test_lifecycle(): + async with lifespan(app) as ls: + client = TestClient(app) + r = client.get("/metrics") + assert r.status_code == 401 + + auth = httpx.BasicAuth(username="prom", password="super_secure") + r = client.get("/metrics", auth=auth) + assert r.status_code == 200, r.text diff --git a/ooniapi/services/oonirun/tests/test_oonirun.py b/ooniapi/services/oonirun/tests/test_oonirun.py index 871f01d5..6faaa2cd 100644 --- a/ooniapi/services/oonirun/tests/test_oonirun.py +++ b/ooniapi/services/oonirun/tests/test_oonirun.py @@ -73,103 +73,6 @@ ] -def test_oonirun_models(tmp_path_factory): - db_path = tmp_path_factory.mktemp("oonidb") / "db.sqlite3" - db_url = f"sqlite:///{db_path}" - - engine = create_engine(db_url, connect_args={"check_same_thread": False}) - metadata = models.OONIRunLink.metadata - metadata.create_all(engine) - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - db = SessionLocal() - - run_link = deepcopy(SAMPLE_OONIRUN) - nettests = run_link.pop("nettests") - - db_runlink = models.OONIRunLink( - **run_link, - oonirun_link_id="000000000", - date_created=utcnow_seconds(), - date_updated=utcnow_seconds(), - expiration_date=utcnow_seconds(), - creator_account_id="000000000", - ) - db_runlink.nettests = [ - models.OONIRunLinkNettest( - **nettests[0], - revision=1, - nettest_index=0, - date_created=utcnow_seconds(), - ), - models.OONIRunLinkNettest( - **nettests[1], - revision=1, - nettest_index=1, - date_created=utcnow_seconds(), - ), - models.OONIRunLinkNettest( - **nettests[1], - revision=2, - nettest_index=0, - date_created=utcnow_seconds(), - ), - models.OONIRunLinkNettest( - **nettests[1], - revision=3, - nettest_index=0, - date_created=utcnow_seconds(), - ), - ] - db.add(db_runlink) - db.commit() - - new_row = db.query(models.OONIRunLink).first() - assert new_row - assert new_row.nettests[0].revision == 3 - - db.close() - - with pytest.raises(sa.exc.StatementError): - db_runlink = models.OONIRunLink( - **run_link, - oonirun_link_id="000000000", - date_created="NOT A DATE", - date_updated=utcnow_seconds(), - expiration_date=utcnow_seconds(), - creator_account_id="000000000", - ) - db.add(db_runlink) - db.commit() - db.rollback() - - with pytest.raises(sa.exc.StatementError): - naive_datetime = datetime.now() - db_runlink = models.OONIRunLink( - **run_link, - oonirun_link_id="000000000", - date_created=naive_datetime, - date_updated=utcnow_seconds(), - expiration_date=utcnow_seconds(), - creator_account_id="000000000", - ) - db.add(db_runlink) - db.commit() - db.rollback() - - with pytest.raises(sa.exc.StatementError): - db_runlink = models.OONIRunLink( - **run_link, - oonirun_link_id="000000000", - date_created=None, - date_updated=utcnow_seconds(), - expiration_date=utcnow_seconds(), - creator_account_id="000000000", - ) - db.add(db_runlink) - db.commit() - db.rollback() - - def test_get_version(client): r = client.get("/version") j = r.json()