diff --git a/ooniapi/common/src/common/auth.py b/ooniapi/common/src/common/auth.py new file mode 100644 index 00000000..480a5d38 --- /dev/null +++ b/ooniapi/common/src/common/auth.py @@ -0,0 +1,54 @@ +import hashlib +from typing import Optional, Dict, Any +import jwt + +def hash_email_address(email_address: str, key: str) -> str: + em = email_address.encode() + return hashlib.blake2b(em, key=key.encode("utf-8"), digest_size=16).hexdigest() + + +def decode_jwt(token: str, key: str, **kw) -> Dict[str, Any]: + tok = jwt.decode(token, key, algorithms=["HS256"], **kw) + return tok + + +def create_jwt(payload: dict, key: str) -> str: + token = jwt.encode(payload, key, algorithm="HS256") + if isinstance(token, bytes): + return token.decode() + else: + return token + + +def get_client_token(authorization: str, jwt_encryption_key: str): + try: + assert authorization.startswith("Bearer ") + token = authorization[7:] + return decode_jwt(token, audience="user_auth", key=jwt_encryption_key) + except: + return None + + +def get_client_role(authorization: str, jwt_encryption_key: str) -> str: + """Raise exception for unlogged users""" + tok = get_client_token(authorization, jwt_encryption_key) + assert tok + return tok["role"] + + +def get_account_id_or_none( + authorization: str, jwt_encryption_key: str +) -> Optional[str]: + """Returns None for unlogged users""" + tok = get_client_token(authorization, jwt_encryption_key) + if tok: + return tok["account_id"] + return None + + +def get_account_id_or_raise(authorization: str, jwt_encryption_key: str) -> str: + """Raise exception for unlogged users""" + tok = get_client_token(authorization, jwt_encryption_key) + if tok: + return tok["account_id"] + raise Exception diff --git a/ooniapi/common/src/common/clickhouse_utils.py b/ooniapi/common/src/common/clickhouse_utils.py index 221041b3..71407aa4 100644 --- a/ooniapi/common/src/common/clickhouse_utils.py +++ b/ooniapi/common/src/common/clickhouse_utils.py @@ -61,7 +61,7 @@ def optimize_table(db: clickhouse_driver.Client, tblname: str) -> None: def raw_query( db: clickhouse_driver.Client, query: Query, query_params: dict, query_prio=1 -): +) -> int: settings = {"priority": query_prio, "max_execution_time": 300} q = db.execute(query, query_params, with_column_types=True, settings=settings) return q diff --git a/ooniapi/common/src/common/config.py b/ooniapi/common/src/common/config.py index 47194843..7ec209c3 100644 --- a/ooniapi/common/src/common/config.py +++ b/ooniapi/common/src/common/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): jwt_encryption_key: str = "CHANGEME" prometheus_metrics_password: str = "CHANGEME" account_id_hashing_key: str = "CHANGEME" + collector_id: str = "CHANGEME" session_expiry_days: int = 10 login_expiry_days: int = 10 diff --git a/ooniapi/common/src/common/dependencies.py b/ooniapi/common/src/common/dependencies.py index 5b4e70af..217b6b08 100644 --- a/ooniapi/common/src/common/dependencies.py +++ b/ooniapi/common/src/common/dependencies.py @@ -3,7 +3,7 @@ from fastapi import Depends from fastapi import HTTPException, Header -from .utils import get_client_token +from .auth import get_client_token from .config import Settings diff --git a/ooniapi/common/src/common/exceptions.py b/ooniapi/common/src/common/exceptions.py new file mode 100644 index 00000000..2481aea3 --- /dev/null +++ b/ooniapi/common/src/common/exceptions.py @@ -0,0 +1,25 @@ +from typing import Dict + +from fastapi import HTTPException + + +class BaseOONIException(HTTPException): + def __init__(self, detail: str, headers: Dict | None): + super.__init__(status_code=400, detail=detail, headers=headers) + + +class InvalidRequest(BaseOONIException): + def __init__(self): + super().__init__(detail="invalid parameters in the request") + + +class OwnershipPermissionError(BaseOONIException): + def __init__(self): + super().__init__( + detail = "attempted to create, update or delete an item belonging to another user" + ) + + +class DatabaseQueryError(BaseOONIException): + def __init__(self): + super().__init__(detail="") \ No newline at end of file diff --git a/ooniapi/common/src/common/utils.py b/ooniapi/common/src/common/utils.py index 4768b508..c1a62fd3 100644 --- a/ooniapi/common/src/common/utils.py +++ b/ooniapi/common/src/common/utils.py @@ -1,11 +1,12 @@ from csv import DictWriter from io import StringIO +from sys import byteorder +from os import urandom import logging -from typing import Any, Dict, List, Optional, Union +from typing import List +from fastapi import Response from fastapi.responses import JSONResponse -import jwt - log = logging.getLogger(__name__) @@ -31,6 +32,15 @@ def jerror(msg, code=400, **kw) -> JSONResponse: return JSONResponse(content=dict(msg=msg, **kw), status_code=code, headers=headers) +def setcacheresponse(interval: str, response: Response): + max_age = int(interval[:-1]) * INTERVAL_UNITS[interval[-1]] + response.headers["Cache-Control"] = f"max-age={max_age}" + + +def setnocacheresponse(response: Response): + response.headers["Cache-Control"] = "no-cache" + + def commasplit(p: str) -> List[str]: assert p is not None out = set(p.split(",")) @@ -60,57 +70,10 @@ def convert_to_csv(r) -> str: return result -def decode_jwt(token: str, key: str, **kw) -> Dict[str, Any]: - tok = jwt.decode(token, key, algorithms=["HS256"], **kw) - return tok - - -def create_jwt(payload: dict, key: str) -> str: - token = jwt.encode(payload, key, algorithm="HS256") - if isinstance(token, bytes): - return token.decode() - else: - return token - - -def get_client_token(authorization: str, jwt_encryption_key: str): +def generate_random_intuid(collector_id: str) -> int: try: - assert authorization.startswith("Bearer ") - token = authorization[7:] - return decode_jwt(token, audience="user_auth", key=jwt_encryption_key) - except: - return None - - -def get_client_role(authorization: str, jwt_encryption_key: str) -> str: - """Raise exception for unlogged users""" - tok = get_client_token(authorization, jwt_encryption_key) - assert tok - return tok["role"] - - -def get_account_id_or_none( - authorization: str, jwt_encryption_key: str -) -> Optional[str]: - """Returns None for unlogged users""" - tok = get_client_token(authorization, jwt_encryption_key) - if tok: - return tok["account_id"] - return None - - -def get_account_id_or_raise(authorization: str, jwt_encryption_key: str) -> str: - """Raise exception for unlogged users""" - tok = get_client_token(authorization, jwt_encryption_key) - if tok: - return tok["account_id"] - raise Exception - - -def get_account_id(authorization: str, jwt_encryption_key: str): - # TODO: switch to get_account_id_or_none - tok = get_client_token(authorization, jwt_encryption_key) - if not tok: - return jerror("Authentication required", 401) - - return tok["account_id"] + collector_id = int(collector_id) + except ValueError: + collector_id = 0 + randint = int.from_bytes(urandom(4), byteorder) + return randint * 100 + collector_id diff --git a/ooniapi/services/oonifindings/.dockerignore b/ooniapi/services/oonifindings/.dockerignore new file mode 100644 index 00000000..4f7a82b5 --- /dev/null +++ b/ooniapi/services/oonifindings/.dockerignore @@ -0,0 +1,10 @@ +.DS_Store +*.log +*.pyc +*.swp +*.env +.coverage +coverage.xml +dist/ +.venv/ +__pycache__/ diff --git a/ooniapi/services/oonifindings/.gitignore b/ooniapi/services/oonifindings/.gitignore new file mode 100644 index 00000000..9a1b4f54 --- /dev/null +++ b/ooniapi/services/oonifindings/.gitignore @@ -0,0 +1,3 @@ +/dist +/coverage_html +*.coverage* diff --git a/ooniapi/services/oonifindings/Dockerfile b/ooniapi/services/oonifindings/Dockerfile new file mode 100644 index 00000000..ce25b72a --- /dev/null +++ b/ooniapi/services/oonifindings/Dockerfile @@ -0,0 +1,33 @@ +# Python builder +FROM python:3.11-bookworm as builder +ARG BUILD_LABEL=docker + +WORKDIR /build + +RUN python -m pip install hatch + +COPY . /build + +# When you build stuff on macOS you end up with ._ files +# https://apple.stackexchange.com/questions/14980/why-are-dot-underscore-files-created-and-how-can-i-avoid-them +RUN find /build -type f -name '._*' -delete + +RUN echo "$BUILD_LABEL" > /build/src/oonifindings/BUILD_LABEL + +RUN hatch build + +### Actual image running on the host +FROM python:3.11-bookworm as runner + +WORKDIR /app + +COPY --from=builder /build/README.md /app/ +COPY --from=builder /build/dist/*.whl /app/ +RUN pip install /app/*whl && rm /app/*whl + +COPY --from=builder /build/alembic/ /app/alembic/ +COPY --from=builder /build/alembic.ini /app/ +RUN rm -rf /app/alembic/__pycache__ + +CMD ["uvicorn", "oonifindings.main:app", "--host", "0.0.0.0", "--port", "80"] +EXPOSE 80 diff --git a/ooniapi/services/oonifindings/LICENSE.txt b/ooniapi/services/oonifindings/LICENSE.txt new file mode 100644 index 00000000..3ec29c80 --- /dev/null +++ b/ooniapi/services/oonifindings/LICENSE.txt @@ -0,0 +1,26 @@ +Copyright 2022-present Open Observatory of Network Interference Foundation (OONI) ETS + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/ooniapi/services/oonifindings/Makefile b/ooniapi/services/oonifindings/Makefile new file mode 100644 index 00000000..e69de29b diff --git a/ooniapi/services/oonifindings/README.md b/ooniapi/services/oonifindings/README.md new file mode 100644 index 00000000..1e370a82 --- /dev/null +++ b/ooniapi/services/oonifindings/README.md @@ -0,0 +1,21 @@ +# oonifindings + +[![PyPI - Version](https://img.shields.io/pypi/v/oonifindings.svg)](https://pypi.org/project/oonifindings) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/oonifindings.svg)](https://pypi.org/project/oonifindings) + +----- + +**Table of Contents** + +- [Installation](#installation) +- [License](#license) + +## Installation + +```console +pip install oonifindings +``` + +## License + +`oonifindings` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/ooniapi/services/oonifindings/buildspec.yml b/ooniapi/services/oonifindings/buildspec.yml new file mode 100644 index 00000000..e69de29b diff --git a/ooniapi/services/oonifindings/migrations/clickhouse_fixtures.sql b/ooniapi/services/oonifindings/migrations/clickhouse_fixtures.sql new file mode 100644 index 00000000..e69de29b diff --git a/ooniapi/services/oonifindings/migrations/clickhouse_init_tables.sql b/ooniapi/services/oonifindings/migrations/clickhouse_init_tables.sql new file mode 100644 index 00000000..009d7feb --- /dev/null +++ b/ooniapi/services/oonifindings/migrations/clickhouse_init_tables.sql @@ -0,0 +1,28 @@ +-- Create tables for integration tests + +CREATE TABLE default.incidents +( + `id` String, + `title` String, + `short_description` String, + `text` String, + `start_time` Datetime DEFAULT now(), + `end_time` Nullable(Datetime), + `create_time` Datetime, + `update_time` Datetime DEFAULT now(), + `creator_account_id` FixedString(32), + `reported_by` String, + `email_address` String, + `event_type` LowCardinality(String), + `published` UInt8, + `deleted` UInt8 DEFAULT 0, + `CCs` Array(FixedString(2)), + `ASNs` Array(String), + `domains` Array(String), + `tags` Array(String), + `links` Array(String), + `test_names` Array(String), +) +ENGINE=ReplacingMergeTree +ORDER BY (create_time, creator_account_id, id) +SETTINGS index_granularity = 8192; \ No newline at end of file diff --git a/ooniapi/services/oonifindings/pyproject.toml b/ooniapi/services/oonifindings/pyproject.toml new file mode 100644 index 00000000..b7f7c46a --- /dev/null +++ b/ooniapi/services/oonifindings/pyproject.toml @@ -0,0 +1,106 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "oonifindings" +dynamic = ["version"] +description = '' + +dependencies = [ + "fastapi ~= 0.108.0", + "clickhouse-driver ~= 0.2.6", + "sqlalchemy ~= 2.0.27", + "pydantic-settings ~= 2.1.0", + "uvicorn ~= 0.25.0", + "statsd ~= 4.0.1", + "uvicorn ~= 0.25.0", + "httpx ~= 0.26.0", + "pyjwt ~= 2.8.0", + "prometheus-fastapi-instrumentator ~= 6.1.0", + "prometheus-client", +] + +readme = "README.md" +requires-python = ">=3.11" +license = "BSD-3-Clause" +keywords = [] +authors = [ + { name = "OONI", email = "contact@ooni.org" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] + +[project.urls] +Documentation = "https://docs.ooni.org" +Issues = "https://github.com/ooni/backend/issues" +Source = "https://github.com/ooni/backend" + +[tool.hatch.version] +path = "src/oonifindings/__about__.py" + +[tool.hatch.build.targets.sdist] +include = ["BUILD_LABEL"] + +[tool.hatch.build.targets.wheel] +packages = ["src/oonifindings"] +artifacts = ["BUILD_LABEL"] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.envs.default] +dependencies = [ + "pytest", + "pytest-cov", + "click", + "black", + "pytest-asyncio", + "pytest-clickhouse @ {root:parent:parent:parent:uri}/pytest-clickhouse/dist/pytest_clickhouse-0.0.1-py3-none-any.whl" +] +path = ".venv/" + +[tool.hatch.envs.default.scripts] +test = "pytest {args:tests}" +test-cov = "pytest -s --full-trace --log-level=INFO --log-cli-level=INFO -v --setup-show --cov=./ --cov-report=xml --cov-report=html --cov-report=term {args:tests}" +cov-report = ["coverage report"] +cov = ["test-cov", "cov-report"] + +[[tool.hatch.envs.all.matrix]] +python = ["3.8", "3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.types] +dependencies = [ + "mypy>=1.0.0", +] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:src/oonifindings tests}" + +[tool.coverage.run] +source_pkgs = ["oonifindings", "tests"] +branch = true +parallel = true +omit = [ + "src/oonifindings/common/*", + "src/oonifindings/__about__.py" +] + +[tool.coverage.paths] +oonifindings = ["src/oonifindings", "*/oonifindings/src/oonifindings"] +tests = ["tests", "*/oonifindings/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/ooniapi/services/oonifindings/scripts/docker-smoketest.sh b/ooniapi/services/oonifindings/scripts/docker-smoketest.sh new file mode 100644 index 00000000..fc17b55e --- /dev/null +++ b/ooniapi/services/oonifindings/scripts/docker-smoketest.sh @@ -0,0 +1,34 @@ +h + +set -ex + +if [ $# -eq 0 ]; then + echo "Error: No Docker image name provided." + echo "Usage: $0 [IMAGE_NAME]" + exit 1 +fi + +IMAGE=$1 +CONTAINER_NAME=ooniapi-smoketest-$RANDOM +PORT=$((RANDOM % 10001 + 30000)) + +cleanup() { + echo "cleaning up" + docker logs $CONTAINER_NAME + docker stop $CONTAINER_NAME >/dev/null 2>&1 + docker rm $CONTAINER_NAME >/dev/null 2>&1 +} + +echo "[+] Running smoketest of ${IMAGE}" +docker run -d --name $CONTAINER_NAME -p $PORT:80 ${IMAGE} + +trap cleanup INT TERM EXIT + +sleep 2 +response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$PORT/health) +if [ "${response}" -eq 200 ]; then + echo "Smoke test passed: Received 200 OK from /health endpoint." +else + echo "Smoke test failed: Did not receive 200 OK from /health endpoint. Received: $response" + exit 1 +fi diff --git a/ooniapi/services/oonifindings/src/oonifindings/__about__.py b/ooniapi/services/oonifindings/src/oonifindings/__about__.py new file mode 100644 index 00000000..a75e15d4 --- /dev/null +++ b/ooniapi/services/oonifindings/src/oonifindings/__about__.py @@ -0,0 +1,2 @@ +# TODO(decfox): set VERSION here +VERSION = "0.0.1" diff --git a/ooniapi/services/oonifindings/src/oonifindings/__init__.py b/ooniapi/services/oonifindings/src/oonifindings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ooniapi/services/oonifindings/src/oonifindings/common b/ooniapi/services/oonifindings/src/oonifindings/common new file mode 120000 index 00000000..3f599f25 --- /dev/null +++ b/ooniapi/services/oonifindings/src/oonifindings/common @@ -0,0 +1 @@ +../../../../common/src/common \ No newline at end of file diff --git a/ooniapi/services/oonifindings/src/oonifindings/dependencies.py b/ooniapi/services/oonifindings/src/oonifindings/dependencies.py new file mode 100644 index 00000000..547d115e --- /dev/null +++ b/ooniapi/services/oonifindings/src/oonifindings/dependencies.py @@ -0,0 +1,15 @@ +from typing import Annotated + +from fastapi import Depends + +from clickhouse_driver import Client as Clickhouse + +from .common.config import Settings +from .common.dependencies import get_settings + +def get_clickhouse_session(settings: Annotated[Settings, Depends(get_settings)]): + db = Clickhouse.from_url(settings.clickhouse_url) + try: + yield db + finally: + db.disconnect_connection() diff --git a/ooniapi/services/oonifindings/src/oonifindings/main.py b/ooniapi/services/oonifindings/src/oonifindings/main.py new file mode 100644 index 00000000..a262c6ff --- /dev/null +++ b/ooniapi/services/oonifindings/src/oonifindings/main.py @@ -0,0 +1,110 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from pydantic import BaseModel + +from prometheus_fastapi_instrumentator import Instrumentator + +from .routers import oonifindings + +from .dependencies import get_settings, get_clickhouse_session +from .common.version import get_build_label, get_pkg_version +from .common.clickhouse_utils import query_click +from .common.metrics import mount_metrics + + +pkg_name = "oonifindings" + +pkg_version = get_pkg_version(pkg_name) +build_label = get_build_label(pkg_name) + +log = logging.getLogger(__name__) + + +@asynccontextmanager +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="oonifindings" +) + +# TODO: temporarily enable all +origins = ["*"] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(oonifindings.router, prefix="/api") + + +@app.get("/version") +async def version(): + return {"version": pkg_version, "build_label": build_label} + + +class HealthStatus(BaseModel): + status: str + errors: list[str] = [] + version: str + build_label: str + + +# TODO(decfox): Add minimal health check functionality +@app.get("/health") +async def health( + settings=Depends(get_settings), + db=Depends(get_clickhouse_session), +): + errors = [] + + try: + query = f"""SELECT id, update_time, start_time, end_time, reported_by, + title, event_type, published, CCs, ASNs, domains, tags, test_names, + links, short_description, email_address, create_time, creator_account_id + FROM incidents FINAL + """ + query_click(db=db, query=query, query_params={}) + except Exception as exc: + log.error(exc) + errors.append("db_error") + + if settings.jwt_encryption_key == "CHANGEME": + err = "bad_jwt_secret" + log.error(err) + errors.append(err) + + if settings.prometheus_metrics_password == "CHANGEME": + err = "bad_prometheus_password" + log.error(err) + errors.append(err) + + if len(errors) > 0: + raise HTTPException(status_code=400, detail="health check failed") + + status = "ok" + + return { + "status": status, + "errors": errors, + "version": pkg_version, + "build_label": build_label, + } + + +@app.get("/") +async def root(): + return {"message": "Hello OONItarian"} \ No newline at end of file diff --git a/ooniapi/services/oonifindings/src/oonifindings/routers/oonifindings.py b/ooniapi/services/oonifindings/src/oonifindings/routers/oonifindings.py new file mode 100644 index 00000000..fb96cf16 --- /dev/null +++ b/ooniapi/services/oonifindings/src/oonifindings/routers/oonifindings.py @@ -0,0 +1,459 @@ +""" +OONIFindings incidents management +""" + +from datetime import datetime, date, timezone +from typing import List, Dict +import logging + +from clickhouse_driver import Client as Clickhouse +from fastapi import APIRouter, Depends, Header, Response, HTTPException + +from pydantic import Field + +from ..common.routers import BaseModel +from ..common.dependencies import get_settings, role_required +from ..common.auth import ( + hash_email_address, + get_account_id_or_raise, + get_account_id_or_none, + get_client_role +) +from ..common.exceptions import InvalidRequest, OwnershipPermissionError, BaseOONIException +from ..common.utils import setnocacheresponse, generate_random_intuid +from ..common.clickhouse_utils import query_click, raw_query, insert_click, optimize_table +from ..dependencies import get_clickhouse_session + +log = logging.getLogger(__name__) + +router = APIRouter() + + +def utcnow_seconds(): + return datetime.now(timezone.utc).replace(microsecond=0) + + +class OONIFindingBase(BaseModel): + incident_id: str + title: str = Field( + default="", title="title of the ooni finding" + ) + short_description: str = Field( + default="", title="short description of the ooni finding" + ) + start_time: datetime = Field( + description="time when the ooni finding started" + ) + create_time: datetime = Field( + description="time when the ooni finding report was created" + ) + reported_by: str = Field( + default="", title="name of the ooni finding reporter" + ) + email_address: str = Field( + default="", title="email address of ooni finding reporter" + ) + text: str = Field( + default="", title="content of the ooni finding report" + ) + published: bool = Field( + default=False, title="binary check if event is published" + ) + event_type: str = Field( + default="", title="type of ooni finding event" + ) + ASNs: List[int] = Field( + default=[], description="list of ASNs associate with the ooni finding" + ) + CCs: List[str] = Field( + default=[], description="list of country codes associated with the ooni finding" + ) + tags: List[str] = Field( + default=[], description="tags associated with the ooni finding" + ) + test_names: List[str] = Field( + default=[], description="ooni tests associated with the ooni finding" + ) + domains: List[str] = Field( + default=[], description="list of domains associated with the ooni finding" + ) + links: List[str] = Field( + default=[], description="links associated with the ooni finding" + ) + + +class OONIFinding(OONIFindingBase): + update_time: datetime = Field( + description="time when the ooni findings incident was last updated" + ) + end_time: datetime = Field( + description="time when the ooni findings incident ended" + ) + mine: bool = Field( + default=False, title="check to see if the client account ID matches the creator account ID" + ) + + +class OONIFindingIncident(BaseModel): + incident: OONIFinding + + +class OONIFindings(BaseModel): + incidents: List[OONIFinding] + + +class OONIFindingUpdate(OONIFindingBase): + pass + + +@router.get( + "/v2/incidents/search", + tags=["oonifindings"], + response_model = OONIFindings +) +def search_list_incidents( + only_mine: bool, + response: Response, + authorization: str = Header("authorization"), + db=Depends(get_clickhouse_session), + settings=Depends(get_settings), +): + """ + Search and list incidents + """ + log.debug("listing incidents") + where = "WHERE deleted != 1" + query_params = {} + + account_id = get_account_id_or_none( + authorization, jwt_encryption_key=settings.jwt_encryption_key + ) + if only_mine: + if account_id is None: + return OONIFindings(incidents=[]) + where += "\nAND creator_account_id = %(account_id)s" + + if account_id is None: + # non-published incidents are not exposed to anon users + where += "\nAND published = 1" + query_params["account_id"] = "never-match" + else: + query_params["account_id"] = account_id + + query = f"""SELECT id, update_time, start_time, end_time, reported_by, + title, event_type, published, CCs, ASNs, domains, tags, test_names, + links, short_description, email_address, create_time, creator_account_id + FROM incidents FINAL + {where} + ORDER BY title + """ + q = query_click(db=db, query=query, query_params=query_params) + + incidents = list(q) + client_role = get_client_role(authorization, jwt_encryption_key=settings.jwt_encryption_key) + for incident in incidents: + incident["published"] = bool(incident["published"]) + if account_id is None or client_role != "admin": + incident["email_address"] = "" + + setnocacheresponse(response) + incident_models = [] + # TODO(decfox): try using OONIFindings.validate_model to populate model + for incident in incidents: + incident_model = OONIFinding( + incident_id=incident.id, + update_time=incident.update_time, + start_time=incident.start_time, + end_time=incident.end_time, + reported_by=incident.reported_by, + title=incident.title, + text=incident.text, + event_type=incident.event_type, + published=incident.published, + CCs=incident.CCs, + ASNs=incident.ASNs, + domains=incident.domains, + tags=incident.tags, + test_names=incident.test_names, + links=incident.links, + short_description=incident.short_description, + email_address=incident.email_address, + create_time=incident.create_time, + mine=account_id == incident.creator_account_id, + ) + incident_models.append(incident_model) + return OONIFindings(incidents=incident_models) + + +@router.get( + "/v2/incidents/show/{incident_id}", + tags=["oonifindings"], + response_model=OONIFinding +) +def show_incident( + incident_id: str, + response: Response, + authorization: str = Header("authorization"), + db=Depends(get_clickhouse_session), + settings=Depends(get_settings) +): + """ + Returns an incident + """ + log.debug("showing incident") + where = "WHERE id = %(id)s AND deleted != 1" + account_id = get_account_id_or_none( + authorization, jwt_encryption_key=settings.jwt_encryption_key + ) + if account_id is None: + # non-published incidents are not exposed to anon users + where += "\nAND published = 1" + query_params = {"id": incident_id, "account_id": "never-match"} + else: + query_params = {"id": incident_id, "account_id": account_id} + + query = f"""SELECT id, update_time, start_time, end_time, reported_by, + title, text, event_type, published, CCs, ASNs, domains, tags, test_names, + links, short_description, email_address, create_time, creator_account_id + FROM incidents FINAL + {where} + LIMIT 1 + """ + q = query_click(db=db, query=query, query_params=query_params) + if len(q) < 1: + raise HTTPException(status_code=404, detail="Incident not found") + + incident = q[0] + incident["published"] = bool(incident["published"]) + client_role = get_client_role(authorization, jwt_encryption_key=settings.jwt_encryption_key) + if account_id is None or client_role != "admin": + incident["email_address"] = "" # hide email + + # TODO: cache if possible + setnocacheresponse(response) + # TODO(decfox): try using OONIFinding.validate_model to populate model + incident_model = OONIFinding( + incident_id=incident.id, + update_time=incident.update_time, + start_time=incident.start_time, + end_time=incident.end_time, + reported_by=incident.reported_by, + title=incident.title, + text=incident.text, + event_type=incident.event_type, + published=incident.published, + CCs=incident.CCs, + ASNs=incident.ASNs, + domains=incident.domains, + tags=incident.tags, + test_names=incident.test_names, + links=incident.links, + short_description=incident.short_description, + email_address=incident.email_address, + create_time=incident.create_time, + mine=account_id == incident.creator_account_id, + ) + return OONIFindingIncident(incident=incident_model) + + +def prepare_incident_dict( + authorization: str, + jwt_encryption_key: str, + update_incident: OONIFindingUpdate +) -> Dict: + d = update_incident.dict() + d["creator_account_id"] = get_account_id_or_raise(authorization, jwt_encryption_key=jwt_encryption_key) + exp = [ + "ASNs", + "CCs", + "create_time", + "creator_account_id", + "domains", + "email_address", + "end_time", + "event_type", + "id", + "links", + "published", + "reported_by", + "short_description", + "start_time", + "tags", + "test_names", + "text", + "title", + ] + if sorted(d) != exp: + log.debug(f"Invalid incident update request. Keys: {sorted(d)}") + raise InvalidRequest() + + ts_fmt = "%Y-%m-%dT%H:%M:%SZ" + d["start_time"] = datetime.strptime(d["start_time"], ts_fmt) + d["create_time"] = datetime.strptime(d["create_time"], ts_fmt) + if d["end_time"] is not None: + d["end_time"] = datetime.strptime(d["end_time"], ts_fmt) + delta = d["end_time"] - d["start_time"] + if delta.total_seconds() < 0: + raise InvalidRequest() + + if not d["title"] or not d["text"]: + log.debug("Invalid incident update request: empty title or desc") + raise InvalidRequest() + + return d + + +def mismatched_email_addr( + authorization: str, + jwt_encryption_key: str, + email_address: str, + hashing_key: str +) -> bool: + account_id = get_account_id_or_raise(authorization, jwt_encryption_key=jwt_encryption_key) + hashed = hash_email_address(email_address, hashing_key=hashing_key) + if account_id == hashed: + return False + + log.info(f"Email mismatch {hashed} {account_id}") + return True + + +def user_cannot_update( + db: Clickhouse, + authorization: str, + jwt_encryption_key: str, + incident_id: str, +) -> bool: + # Check if there is already an incident and belongs to a different user + query = """SELECT count() AS cnt + FROM incidents FINAL + WHERE deleted != 1 + AND id = %(incident_id)s + AND creator_account_id != %(account_id)s + """ + account_id = get_account_id_or_raise(authorization, jwt_encryption_key=jwt_encryption_key) + query_params = dict(incident_id=incident_id, account_id=account_id) + q = query_click(db, query, query_params) + return q[0]["cnt"] > 0 + + +class OONIFindingsUpdate(BaseModel): + r: int = Field( + default=0, title="result of the update operation" + ) + incident_id: str = Field( + default="", title="incident id of the updated ooni finding" + ) + + +@router.post( + "/v2/incidents/{action}", + dependencies=[Depends(role_required(["admin", "user"]))], + tags=["oonifindings"], + response_model=OONIFindingsUpdate +) +def post_update_incident( + action: str, + update_incident: OONIFindingUpdate, + response: Response, + authorization: str = Header("authorization"), + db=Depends(get_clickhouse_session), + settings=Depends(get_settings) +): + """ + Create/update/publish/unpublish/delete an incident. + The `action` value can be "create", "update", "delete", "publish", "unpublish". + The `id` value is returned by the API when an incident is created. + It should be set by the caller for incident update or deletion. + """ + if action not in ("create", "update", "delete", "publish", "unpublish"): + raise HTTPException(status_code=400, details="Invalid action encountered") + assert update_incident + + client_role = get_client_role(authorization, jwt_encryption_key=settings.jwt_encryption_key) + + incident_dict = dict() + if action == "create": + incident_id = str(generate_random_intuid(collector_id=settings.collector_id)) + if client_role != "admin" and mismatched_email_addr( + authorization, + jwt_encryption_key=settings.jwt_encryption_key, + email_address=update_incident.email_address, + hashing_key=settings.account_id_hashing_key + ): + raise InvalidRequest() + + if client_role != "admin" and update_incident.published: + raise InvalidRequest() + + update_incident.incident_id = incident_id + update_incident.create_time = utcnow_seconds() + incident_dict = prepare_incident_dict( + authorization, jwt_encryption_key=settings.jwt_encryption_key, update_incident=update_incident + ) + log.info(f"Creating incident {incident_id}") + + else: + incident_id = update_incident.incident_id + # When incident already exists: + assert incident_id + + # Only admin or incident owner can make changes + if client_role != "admin": + if user_cannot_update( + db, authorization, jwt_encryption_key=settings.jwt_encryption_key, incident_id=incident_id + ): + raise OwnershipPermissionError() + + # ...while using the right email addr + if mismatched_email_addr( + authorization, + jwt_encryption_key=settings.jwt_encyption_key, + email_address=update_incident.email_address, + hashing_key=settings.account_id_hashing_key + ): + raise InvalidRequest() + + if action == "delete": + # TODO: switch to faster deletion with new db version + query = "ALTER TABLE incidents DELETE WHERE id = %(incident_id)s" + r = raw_query(db, query, {"incident_id": incident_id}) + optimize_table("incidents") + setnocacheresponse(response) + # TODO: replace with response_model + return OONIFindingsUpdate(r=r, incident_id=incident_id) + + if action == "update": + # Only admin can publish + if client_role != "admin" and update_incident.published: + raise InvalidRequest() + + incident_dict = prepare_incident_dict( + authorization, jwt_encryption_key=settings.jwt_encryption_key, update_incident=update_incident + ) + log.info(f"Updating incident {incident_id}") + + elif action in ("publish", "unpublish"): + if client_role != "admin": + raise InvalidRequest() + + query = "SELECT * FROM incidents FINAL WHERE id = %(incident_id)s" + q = query_click(db, query, {"incident_id": incident_id}) + if len(q) < 1: + return HTTPException(status_code=404, detail="Incident not found") + incident_dict = q[0] + incident_dict["published"] = bool(action == "publish") + + insert_query = """INSERT INTO incidents + (id, start_time, end_time, creator_account_id, reported_by, title, + text, event_type, published, CCs, ASNs, domains, tags, links, + test_names, short_description, email_address, create_time) + VALUES + """ + r = insert_click(db, insert_query, [incident_dict]) + log.debug(f"Result: {r}") + optimize_table(db, tblname="incidents") + + setnocacheresponse(response) + return OONIFindingsUpdate(r=r, incident_id=incident_id) + \ No newline at end of file diff --git a/ooniapi/services/oonifindings/tests/__init__.py b/ooniapi/services/oonifindings/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ooniapi/services/oonifindings/tests/conftest.py b/ooniapi/services/oonifindings/tests/conftest.py new file mode 100644 index 00000000..d2e9085b --- /dev/null +++ b/ooniapi/services/oonifindings/tests/conftest.py @@ -0,0 +1,100 @@ +from pathlib import Path +import pytest + +import time +import jwt + +from fastapi.testclient import TestClient +from clickhouse_driver import Client as Clickhouse + +from oonifindings.common.config import Settings +from oonifindings.common.dependencies import get_settings +from oonifindings.main import app + + +def run_migration(path: Path, click: Clickhouse): + sql_no_comment = "\n".join( + filter(lambda x: not x.startswith("--"), path.read_text().split("\n")) + ) + queries = sql_no_comment.split(";") + for q in queries: + q = q.strip() + if not q: + continue + click.execute(q) + + +@pytest.fixture +def clickhouse_migration(clickhouse): + migrations_path = (Path(__file__).parent.parent / "migrations").resolve() + + db_url = f"clickhouse://{clickhouse.connection.host}:{clickhouse.connection.port}" + + for fn in migrations_path.iterdir(): + sql_file = fn.resolve() + run_migration(sql_file, clickhouse) + + yield db_url + + +def make_override_get_settings(**kw): + def override_get_settings(): + return Settings(**kw) + + return override_get_settings + + +@pytest.fixture +def client_with_bad_settings(): + app.dependency_overrides[get_settings] = make_override_get_settings( + clickhouse_url = "clickhouse://badhost:9000" + ) + + client = TestClient(app) + yield client + + +@pytest.fixture +def client(clickhouse_migration): + app.dependency_overrides[get_settings] = make_override_get_settings( + clickhouse_url=clickhouse_migration, + jwt_encryption_key="super_secure", + prometheus_metrics_password="super_secure" + ) + + client = TestClient(app) + yield(client) + + +def create_jwt(payload: dict) -> str: + return jwt.encode(payload, "super_secure", algorithm="HS256") + + +def create_session_token(account_id: str, role: str) -> str: + now = int(time.time()) + payload = { + "nbf": now, + "iat": now, + "exp": now + 10 * 86400, + "aud": "user_auth", + "account_id": account_id, + "login_time": None, + "role": role, + } + return create_jwt(payload) + + +@pytest.fixture +def client_with_user_role(client): + client = TestClient(app) + jwt_token = create_session_token("0" * 16, "user") + client.headers = {"Authorization": f"Bearer {jwt_token}"} + yield client + + +@pytest.fixture +def client_with_admin_role(client): + client = TestClient(app) + jwt_token = create_session_token("0" * 16, "admin") + client.headers = {"Authorization": f"Bearer {jwt_token}"} + yield client diff --git a/ooniapi/services/oonifindings/tests/fixtures/clickhouse_1_schema.sql b/ooniapi/services/oonifindings/tests/fixtures/clickhouse_1_schema.sql new file mode 100644 index 00000000..e69de29b diff --git a/ooniapi/services/oonifindings/tests/fixtures/clickhouse_2_fixtures.sql b/ooniapi/services/oonifindings/tests/fixtures/clickhouse_2_fixtures.sql new file mode 100644 index 00000000..e69de29b diff --git a/ooniapi/services/oonifindings/tests/test_integration.py b/ooniapi/services/oonifindings/tests/test_integration.py new file mode 100644 index 00000000..512c7d46 --- /dev/null +++ b/ooniapi/services/oonifindings/tests/test_integration.py @@ -0,0 +1,42 @@ +import os +import time +import random + +from multiprocessing import Process + +import httpx +import pytest +import uvicorn + + +LISTEN_PORT = random.randint(30_000, 42_000) + + +@pytest.fixture +def server(clickhouse_migration): + os.environ["CLICKHOUSE_URL"] = clickhouse_migration + proc = Process( + target=uvicorn.run, + args=("oonifindings.main:app"), + kwargs={"host": "127.0.0.1", "port": LISTEN_PORT, "log_level": "info"}, + daemon=True, + ) + + proc.start() + # Give it as second to start + time.sleep(1) + yield + proc.kill() + # Note: coverage is not being calculated properly + # TODO(art): https://pytest-cov.readthedocs.io/en/latest/subprocess-support.html + proc.join() + + +def test_integration(server): + with httpx.Client(base_url=f"https://120.0.0.1:{LISTEN_PORT}") as client: + r = client.get("/version") + assert r.status_code == 200 + r = client.get("/api/v2/incidents/search") + j = r.json() + assert isinstance(j["incidents"], list) + diff --git a/ooniapi/services/oonifindings/tests/test_main.py b/ooniapi/services/oonifindings/tests/test_main.py new file mode 100644 index 00000000..17832724 --- /dev/null +++ b/ooniapi/services/oonifindings/tests/test_main.py @@ -0,0 +1,35 @@ +import pytest + +import httpx +from fastapi.testclient import TestClient +from oonifindings.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): + 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 \ No newline at end of file diff --git a/ooniapi/services/oonifindings/tests/test_oonifindings.py b/ooniapi/services/oonifindings/tests/test_oonifindings.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest-clickhouse/LICENSE.txt b/pytest-clickhouse/LICENSE.txt new file mode 100644 index 00000000..d32613ca --- /dev/null +++ b/pytest-clickhouse/LICENSE.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024-present DecFox + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/pytest-clickhouse/README.md b/pytest-clickhouse/README.md new file mode 100644 index 00000000..8d6d7d21 --- /dev/null +++ b/pytest-clickhouse/README.md @@ -0,0 +1,21 @@ +# pytest-clickhouse + +[![PyPI - Version](https://img.shields.io/pypi/v/pytest-clickhouse.svg)](https://pypi.org/project/pytest-clickhouse) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-clickhouse.svg)](https://pypi.org/project/pytest-clickhouse) + +----- + +**Table of Contents** + +- [Installation](#installation) +- [License](#license) + +## Installation + +```console +pip install pytest-clickhouse +``` + +## License + +`pytest-clickhouse` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/pytest-clickhouse/dist/pytest_clickhouse-0.0.1-py3-none-any.whl b/pytest-clickhouse/dist/pytest_clickhouse-0.0.1-py3-none-any.whl new file mode 100644 index 00000000..1901830c Binary files /dev/null and b/pytest-clickhouse/dist/pytest_clickhouse-0.0.1-py3-none-any.whl differ diff --git a/pytest-clickhouse/dist/pytest_clickhouse-0.0.1.tar.gz b/pytest-clickhouse/dist/pytest_clickhouse-0.0.1.tar.gz new file mode 100644 index 00000000..3bd60aff Binary files /dev/null and b/pytest-clickhouse/dist/pytest_clickhouse-0.0.1.tar.gz differ diff --git a/pytest-clickhouse/pyproject.toml b/pytest-clickhouse/pyproject.toml new file mode 100644 index 00000000..55531cee --- /dev/null +++ b/pytest-clickhouse/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pytest-clickhouse" +dynamic = ["version"] +description = 'pytest plugin for clickhouse' + +dependencies = [ + "clickhouse-driver ~= 0.2.6", + "docker ~= 6.1.3", +] + +readme = "README.md" +requires-python = ">=3.8" +license = "BSD-3-Clause" +keywords = [] +authors = [ + { name = "OONI", email = "contact@ooni.org" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] + +[project.urls] +Documentation = "https://docs.ooni.org" +Issues = "https://github.com/ooni/backend/issues" +Source = "https://github.com/ooni/backend" + +[project.entry-points.pytest11] +pytest_clickhouse = "pytest_clickhouse.plugin" + +[tool.hatch.version] +path = "pytest_clickhouse/__about__.py" + +[tool.hatch.envs.default] +dependencies = [ + "pytest", + "pytest-cov", + "black", + "click", +] +path=".venv/" + +[tool.hatch.envs.default.scripts] +test = "pytesli-lt {args:tests}" +test-cov = "pytest -s --full-trace --log-level=INFO --log-cevel=INFO -v --setup-show --cov=./ --cov-report=xml --cov-report=html --cov-report=term {args:tests}" +cov-report = ["coverage report"] +cov = ["test-cov", "cov-report"] + +[[tool.hatch.envs.all.matrix]] +python = ["3.8", "3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.types] +dependencies = [ + "mypy>=1.0.0", +] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:pytest_clickhouse tests}" + +[tool.coverage.run] +source_pkgs = ["pytest_clickhouse", "tests"] +branch = true +parallel = true +omit = [ + "pytest_clickhouse/__about__.py", +] + +[tool.coverage.paths] +pytest_clickhouse = ["pytest_clickhouse", "*/pytest-clickhouse/pytest_clickhouse"] +tests = ["tests", "*/pytest-clickhouse/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/pytest-clickhouse/pytest_clickhouse/__about__.py b/pytest-clickhouse/pytest_clickhouse/__about__.py new file mode 100644 index 00000000..f102a9ca --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/__about__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/pytest-clickhouse/pytest_clickhouse/__init__.py b/pytest-clickhouse/pytest_clickhouse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest-clickhouse/pytest_clickhouse/config.py b/pytest-clickhouse/pytest_clickhouse/config.py new file mode 100644 index 00000000..d555f467 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/config.py @@ -0,0 +1,32 @@ +from pathlib import Path +from typing import TypedDict, Optional, List, Union + +from pytest import FixtureRequest + + +class ClickhouseConfigDict(TypedDict): + """ + Typed Config Dictionary + """ + + image_name: str + host: str + port: Optional[str] + dbname: str + + +def get_config(request: FixtureRequest) -> ClickhouseConfigDict: + """ + Return a dictionary with config options + """ + + def get_clickhouse_option(option: str) -> any: + name = f"clickhouse_{option}" + return request.config.getoption(name) or request.config.getini(name) + + return ClickhouseConfigDict( + image_name=get_clickhouse_option("image"), + host=get_clickhouse_option("host"), + port=get_clickhouse_option("port"), + dbname=get_clickhouse_option("dbname"), + ) diff --git a/pytest-clickhouse/pytest_clickhouse/executor.py b/pytest-clickhouse/pytest_clickhouse/executor.py new file mode 100644 index 00000000..360fe5f5 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/executor.py @@ -0,0 +1,70 @@ +from typing import Optional +import time + +import docker +from docker.models.containers import Container as DockerContainer + +class ClickhouseExecutor: + """ + Clickhouse executor running on docker + """ + + + def __init__(self, image_name: str, dbname: str): + self.image_name = image_name + self.container = None + self.clickhouse_url = "" + self.dbname = dbname + + + def start(self): + client = docker.from_env() + image_name = self.image_name + client.images.pull(image_name) + + # run a container with a random port mapping for ClickHouse default port 9000 + container = client.containers.run( + image_name, + ports={ + "9000/tcp": None + }, + detach=True, + ) + assert isinstance(container, DockerContainer) + self.container = container + + # obtain the port mapping + container.reload() + assert isinstance(container.attrs, dict) + host_port = container.attrs["NetworkSettings"]["Ports"]["9000/tcp"][0]["HostPort"] + + # construct the connection string + clickhouse_url = f"clickhouse://localhost:{host_port}" + self.clickhouse_url = clickhouse_url + + + def wait_for_clickhouse(self): + while 1: + if self.running(): + break + time.sleep(1) + + + def running(self) -> bool: + if self.container and self.container.status == 'running': + return True + return False + + + def terminate(self): + if self.container: + self.container.stop() + self.container.remove() + + + def __enter__(self): + self.start() + + + def __exit__(self): + self.terminate() diff --git a/pytest-clickhouse/pytest_clickhouse/executor_noop.py b/pytest-clickhouse/pytest_clickhouse/executor_noop.py new file mode 100644 index 00000000..224726b4 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/executor_noop.py @@ -0,0 +1,20 @@ +from typing import Optional, Union + +class ClickhouseNoopExecutor: + """ + Clickhouse nooperator executor + """ + + + def __init__( + self, + host: str, + port: Union[str, int], + dbname: str + ): + self.host = host + self.port = int(port) + self.clickhouse_url = f"clickhouse://{self.host}:{self.port}" + self.dbname = dbname + + diff --git a/pytest-clickhouse/pytest_clickhouse/factories/__init__.py b/pytest-clickhouse/pytest_clickhouse/factories/__init__.py new file mode 100644 index 00000000..da18216d --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/factories/__init__.py @@ -0,0 +1,5 @@ +from pytest_clickhouse.factories.client import clickhouse +from pytest_clickhouse.factories.process import clickhouse_proc +from pytest_clickhouse.factories.noprocess import clickhouse_noproc + +__all__ = {"clickhouse_proc", "clickhouse_noproc", "clickhouse"} \ No newline at end of file diff --git a/pytest-clickhouse/pytest_clickhouse/factories/client.py b/pytest-clickhouse/pytest_clickhouse/factories/client.py new file mode 100644 index 00000000..771e5dd6 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/factories/client.py @@ -0,0 +1,28 @@ +from typing import Callable, Union, Optional, List + +import pytest +from pytest import FixtureRequest +from clickhouse_driver import Client as Clickhouse + +from pytest_clickhouse.executor import ClickhouseExecutor +from pytest_clickhouse.executor_noop import ClickhouseNoopExecutor +from pytest_clickhouse.janitor import ClickhouseJanitor + +def clickhouse( + process_fixture_name: str, + dbname: Optional[str] = None, + url: Optional[str] = None, +) -> Callable[[FixtureRequest], Clickhouse]: + + @pytest.fixture + def clickhouse_factory(request: FixtureRequest) -> Clickhouse: + proc_fixture: Union[ClickhouseExecutor, ClickhouseNoopExecutor] = request.getfixturevalue( + process_fixture_name + ) + + clickhouse_url = url or proc_fixture.clickhouse_url + + client = Clickhouse.from_url(clickhouse_url) + yield client + + return clickhouse_factory \ No newline at end of file diff --git a/pytest-clickhouse/pytest_clickhouse/factories/noprocess.py b/pytest-clickhouse/pytest_clickhouse/factories/noprocess.py new file mode 100644 index 00000000..0148d875 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/factories/noprocess.py @@ -0,0 +1,34 @@ +from typing import Callable, Optional + +import pytest +from pytest import FixtureRequest + +from pytest_clickhouse.config import get_config +from pytest_clickhouse.executor_noop import ClickhouseNoopExecutor +from pytest_clickhouse.janitor import ClickhouseJanitor + +def clickhouse_noproc( + host: Optional[str] = None, + port: Optional[str] = None, + dbname: Optional[str] = None, +) -> Callable[[FixtureRequest], ClickhouseNoopExecutor]: + + @pytest.fixture(scope="session") + def clickhouse_noproc_fixture( + request: FixtureRequest + ) -> ClickhouseNoopExecutor: + config = get_config(request) + clickhouse_noop_exec = ClickhouseNoopExecutor( + host=host or config["host"], + port=port or config["port"], + dbname=dbname or config["dbname"], + ) + + with clickhouse_noop_exec: + with ClickhouseJanitor( + dbname=clickhouse_noop_exec.dbname, + clickhouse_url=clickhouse_noop_exec.clickhouse_url + ): + yield clickhouse_noop_exec + + return clickhouse_noproc_fixture \ No newline at end of file diff --git a/pytest-clickhouse/pytest_clickhouse/factories/process.py b/pytest-clickhouse/pytest_clickhouse/factories/process.py new file mode 100644 index 00000000..b30c0c80 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/factories/process.py @@ -0,0 +1,33 @@ +from typing import Callable, Iterator, Optional + +import pytest +from pytest import FixtureRequest + +from pytest_clickhouse.executor import ClickhouseExecutor +from pytest_clickhouse.config import get_config +from pytest_clickhouse.janitor import ClickhouseJanitor + +def clickhouse_proc( + image_name: Optional[str] = None, + dbname: Optional[str] = None, +) -> Callable[[FixtureRequest], ClickhouseExecutor]: + + @pytest.fixture(scope="session") + def clickhouse_proc_fixture( + request: FixtureRequest + ) -> ClickhouseExecutor: + config = get_config(request) + clickhouse_executor = ClickhouseExecutor( + image_name=image_name or config["image_name"], + dbname=dbname or config["dbname"] + ) + + with clickhouse_executor: + clickhouse_executor.wait_for_clickhouse() + with ClickhouseJanitor( + dbname=clickhouse_executor.dbname, + clickhouse_url=clickhouse_executor.clickhouse_url + ): + yield clickhouse_executor + + return clickhouse_proc_fixture \ No newline at end of file diff --git a/pytest-clickhouse/pytest_clickhouse/janitor.py b/pytest-clickhouse/pytest_clickhouse/janitor.py new file mode 100644 index 00000000..78180d97 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/janitor.py @@ -0,0 +1,44 @@ +from contextlib import contextmanager + +from clickhouse_driver import Client as Clickhouse + +class ClickhouseJanitor: + """ + Manage clickhouse database state + """ + + + def __init__(self, dbname: str, clickhouse_url: str): + self.dbname = dbname + self.clickhouse_url = clickhouse_url + self.click = None + + + def init(self): + """ + Initialize client and test database in clickhouse + """ + self.click: Clickhouse = Clickhouse.from_url(self.clickhouse_url) + query = f""" + CREATE DATABASE IF NOT EXISTS {self.dbname} + COMMENT 'test database' + """ + self.click.execute(query, {}) + + + def drop(self): + """ + Drop test database + """ + query = f""" + DROP DATABASE IF EXISTS {self.dbname} + """ + self.click.execute(query, {}) + + + def __enter__(self): + self.init() + + + def __exit__(self): + self.drop() diff --git a/pytest-clickhouse/pytest_clickhouse/plugin.py b/pytest-clickhouse/pytest_clickhouse/plugin.py new file mode 100644 index 00000000..d2448919 --- /dev/null +++ b/pytest-clickhouse/pytest_clickhouse/plugin.py @@ -0,0 +1,58 @@ +from _pytest.config.argparsing import Parser + +from pytest_clickhouse import factories + +_help_image = "Docker image to use to run Clickhouse server" +_help_host = "Host at which Clickhouse will accept connections" +_help_port = "Port at which Clickhouse will accept connections" +_help_dbname = "Default database name" +_help_load = "Dotted-style or entrypoint-style path to callable or path to SQL File" + + +def pytest_addoption(parser: Parser) -> None: + """ + Configure options for pytest-clickhouse + """ + + parser.addini( + name="clickhouse_image", help=_help_image, default="clickhouse/clickhouse-server:24.3-alpine" + ) + + parser.addini(name="clickhouse_host", help=_help_host, default="localhost") + + parser.addini(name="clickhouse_port", help=_help_port, default=None) + + parser.addini(name="clickhouse_dbname", help=_help_dbname, default="tests") + + parser.addoption( + "--clickhouse-image", + action="store", + dest="clickhouse_image", + help=_help_image + ) + + parser.addoption( + "--clickhouse-host", + action="store", + dest="clickhouse_host", + help=_help_host + ) + + parser.addoption( + "--clickhouse-port", + action="store", + dest="clickhouse_port", + help=_help_port + ) + + parser.addoption( + "--clickhouse-dbname", + action="store", + dest="clickhouse_dbname", + help=_help_dbname + ) + + +clickhouse_proc = factories.clickhouse_proc() +clickhouse_noproc = factories.clickhouse_noproc() +clickhouse = factories.clickhouse("clickhouse_proc") \ No newline at end of file diff --git a/pytest-clickhouse/tests/__init__.py b/pytest-clickhouse/tests/__init__.py new file mode 100644 index 00000000..5ddd63fc --- /dev/null +++ b/pytest-clickhouse/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2024-present DecFox +# +# SPDX-License-Identifier: MIT diff --git a/pytest-clickhouse/tests/test_executor.py b/pytest-clickhouse/tests/test_executor.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest-clickhouse/tests/test_janitor.py b/pytest-clickhouse/tests/test_janitor.py new file mode 100644 index 00000000..e69de29b diff --git a/pytest-clickhouse/tests/test_noopexecutor.py b/pytest-clickhouse/tests/test_noopexecutor.py new file mode 100644 index 00000000..e69de29b