diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d19a98..2e34d79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,54 @@ name: CI on: + workflow_dispatch: pull_request: branches: [main, develop] push: branches: [main, develop] +env: + JWT_SECRET: ci-test-secret-key-1234567890 + ENCRYPTION_KEY: ci-test-encryption-key-1234567890abcdef + jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" build + + - name: Run Ruff + run: python -m ruff check backend tests + + - name: Run mypy + run: python -m mypy backend + + - name: Run dependency audit + run: python -m pip_audit + + - name: Export OpenAPI spec + run: python scripts/export_openapi.py + + #- name: Set up Helm + # uses: azure/setup-helm@v4 + + #- name: Lint Helm chart + # run: helm lint ./helm/graphql-meter --set secret.jwtSecret=ci-chart-secret-1234567890 + test: runs-on: ubuntu-latest + needs: quality strategy: matrix: python-version: ["3.12", "3.13", "3.14"] @@ -19,6 +59,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: pip - name: Install dependencies run: | @@ -27,9 +68,6 @@ jobs: - name: Run tests run: python -m pytest tests/ -q --tb=short - env: - JWT_SECRET: ci-test-secret-key-1234567890 - ENCRYPTION_KEY: ci-test-encryption-key-1234567890abcdef build: runs-on: ubuntu-latest @@ -41,13 +79,19 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" + cache: pip - name: Install build tools - run: pip install build + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" build - name: Download vendor libraries run: python -c "from backend.vendor_manager import ensure_vendor_libs; ensure_vendor_libs()" + - name: Export OpenAPI spec + run: python scripts/export_openapi.py + - name: Build wheel and sdist run: python -m build @@ -62,4 +106,36 @@ jobs: uses: actions/upload-artifact@v4 with: name: dist - path: dist/ + path: | + dist/ + reference/openapi.json + + trivy-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies (for lock generation) + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Generate pip freeze lock + run: pip freeze > requirements.lock + + - name: Run Trivy vulnerability scan (filesystem) + uses: aquasecurity/trivy-action@v0.35.0 + with: + scan-type: fs + scan-ref: . + format: table + exit-code: "1" + severity: CRITICAL,HIGH + ignore-unfixed: true + scanners: vuln diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cc8b88..03821bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,7 @@ name: Release on: + workflow_dispatch: push: tags: - "v*" @@ -9,9 +10,12 @@ permissions: contents: write packages: write +env: + JWT_SECRET: ci-test-secret-key-1234567890 + ENCRYPTION_KEY: ci-test-encryption-key-1234567890abcdef + jobs: - # ── Validate ────────────────────────────────────────────── - test: + quality-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,19 +24,24 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" + cache: pip - name: Install dependencies - run: pip install -e ".[dev]" + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run Ruff + run: python -m ruff check backend tests + + - name: Run dependency audit + run: python -m pip_audit - name: Run tests run: python -m pytest tests/ -q --tb=short - env: - JWT_SECRET: ci-test-secret-key-1234567890 - ENCRYPTION_KEY: ci-test-encryption-key-1234567890abcdef - # ── Build artifacts ─────────────────────────────────────── build-wheel: - needs: test + needs: quality-check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -41,13 +50,19 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" + cache: pip - name: Install dependencies - run: pip install build requests + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" build requests - name: Download vendor libraries run: python -c "from backend.vendor_manager import ensure_vendor_libs; ensure_vendor_libs()" + - name: Export OpenAPI spec + run: python scripts/export_openapi.py + - name: Build run: python -m build @@ -60,10 +75,12 @@ jobs: - uses: actions/upload-artifact@v4 with: name: dist - path: dist/ + path: | + dist/ + reference/openapi.json build-windows: - needs: test + needs: quality-check runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -72,10 +89,11 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" + cache: pip - name: Install dependencies run: | - pip install -r requirements.txt + pip install -e ".[dev]" pip install pyinstaller - name: Download vendor libraries @@ -90,8 +108,8 @@ jobs: name: windows-exe path: dist/graphql-meter.exe - # ── Publish GitHub Release ──────────────────────────────── publish-github: + if: startsWith(github.ref, 'refs/tags/') needs: [build-wheel, build-windows] runs-on: ubuntu-latest steps: @@ -107,6 +125,17 @@ jobs: name: windows-exe path: windows/ + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Package Helm chart + run: | + VERSION="${GITHUB_REF_NAME#v}" + sed -i "s/^version: .*/version: ${VERSION}/" helm/graphql-meter/Chart.yaml + sed -i "s/^appVersion: .*/appVersion: \"${VERSION}\"/" helm/graphql-meter/Chart.yaml + mkdir -p release-assets + helm package ./helm/graphql-meter --destination release-assets/ + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -114,15 +143,16 @@ jobs: files: | dist/* windows/graphql-meter.exe + release-assets/*.tgz + - # ── Publish Docker ──────────────────────────────────────── publish-docker: - needs: test + if: startsWith(github.ref, 'refs/tags/') + needs: quality-check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # 1. Add QEMU for multi-platform support (REQUIRED) - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -145,7 +175,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha - type=raw,value=latest + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} - name: Build and push uses: docker/build-push-action@v6 @@ -155,7 +185,22 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - #cache-from: type=gha - #cache-to: type=gha,mode=max - #cache-from: type=gha - #cache-to: type=gha,mode=max + cache-from: type=gha + cache-to: type=gha,mode=max + + #publish-pypi: + # if: ${{ secrets.PYPI_API_TOKEN != '' }} + # needs: build-wheel + # runs-on: ubuntu-latest + # permissions: + # contents: read + # steps: + # - uses: actions/download-artifact@v4 + # with: + # name: dist + # path: dist/ + + # - name: Publish to PyPI + # uses: pypa/gh-action-pypi-publish@release/v1 + # with: + # password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..82250b2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.13 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-merge-conflict diff --git a/Dockerfile b/Dockerfile index ffdf63c..8894d68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,14 @@ # ── Stage 1: k6 binary ────────────────────────────────────── FROM grafana/k6:0.54.0 AS k6 -# ── Stage 2: Build wheels ─────────────────────────────────── +# ── Stage 2: Build runtime env from pyproject ─────────────── FROM python:3.12-slim AS builder WORKDIR /tmp -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY pyproject.toml README.md ./ +COPY backend/ backend/ +COPY frontend/ frontend/ +RUN pip install --no-cache-dir . # ── Stage 3: Runtime ──────────────────────────────────────── FROM python:3.12-slim diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4ba222a --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +.PHONY: install run test lint typecheck security openapi build clean lock migrate + +PYTHON ?= .venv/bin/python +PIP_AUDIT ?= $(PYTHON) -m pip_audit + +install: + $(PYTHON) -m pip install --upgrade pip + $(PYTHON) -m pip install -e ".[dev]" + +run: + $(PYTHON) backend/app.py + +test: + $(PYTHON) -m pytest tests/ -q --tb=short + +lint: + $(PYTHON) -m ruff check backend tests + +typecheck: + $(PYTHON) -m mypy backend + +security: + $(PIP_AUDIT) + +openapi: + $(PYTHON) scripts/export_openapi.py + +build: + $(PYTHON) -m build + +clean: + find . -type d \( -name __pycache__ -o -name .pytest_cache -o -name .ruff_cache \) -prune -exec rm -rf {} + + +lock: + $(PYTHON) -m pip freeze > requirements.lock + +migrate: + $(PYTHON) -m alembic upgrade head diff --git a/README.md b/README.md index 1c12765..a14c2d6 100644 --- a/README.md +++ b/README.md @@ -141,12 +141,30 @@ pip install graphql-meter graphql-meter ``` +### Homebrew (macOS / Linux) + +```bash +brew tap vanditsramblings/tap +brew install graphql-meter +graphql-meter +``` + +> The tap repository will be published alongside the first stable release. Until then, use pipx or Docker. + ### From Source (for development) ```bash git clone https://github.com/vanditsramblings/graphql-meter.git cd graphql-meter -./start.sh +./start.sh # macOS / Linux +# start.bat # Windows (cmd) +# .\start.ps1 # Windows (PowerShell) +make test +make lint +make typecheck +make security +make openapi +pre-commit install ``` ### Kubernetes (Helm) @@ -159,7 +177,7 @@ helm install graphql-meter ./helm/graphql-meter # Install with custom values helm install graphql-meter ./helm/graphql-meter \ - --set env.JWT_SECRET=my-production-secret \ + --set secret.jwtSecret=my-production-secret \ --set persistence.size=5Gi \ --set resources.limits.memory=2Gi @@ -185,7 +203,9 @@ The chart includes: See [`helm/graphql-meter/values.yaml`](helm/graphql-meter/values.yaml) for all configurable values. -`start.sh` performs the following steps: +For production, set either `secret.jwtSecret` or `secret.existingSecret` and pin a release tag or image digest instead of relying on `latest`. + +`start.sh` / `start.bat` / `start.ps1` perform the following steps: 1. Creates a Python virtual environment (`.venv`) 2. Installs the package in editable mode with dev dependencies 3. Copies `.env.example` to `.env` if not present @@ -193,6 +213,8 @@ See [`helm/graphql-meter/values.yaml`](helm/graphql-meter/values.yaml) for all c 5. Downloads the k6 binary for your platform 6. Starts the server on **http://localhost:8899** +**Windows** users can run `start.bat` (cmd) or `.\start.ps1` (PowerShell) — both are equivalent to `start.sh`. + **Manual setup** (if you prefer not to use `start.sh`): ```bash @@ -232,6 +254,7 @@ All settings are controlled via environment variables or a `.env` file. Copy `.e | `HOST` | `0.0.0.0` | Bind address | | `PORT` | `8899` | Listen port | | `DEBUG` | `false` | Enable debug logging | +| `CORS_ORIGINS` | `*` | Comma-separated allowed origins; set explicitly in production | ### Authentication @@ -338,6 +361,28 @@ Create environment profiles with TLS/mTLS settings, client certificates, custom --- +## Database Migrations + +Schema changes are managed with [Alembic](https://alembic.sqlalchemy.org/). On first install the database is created automatically by the application. For subsequent schema changes: + +```bash +# Apply pending migrations +make migrate +# Or directly: +source .venv/bin/activate +alembic upgrade head + +# Create a new migration after a schema change +alembic revision --autogenerate -m "describe_your_change" + +# Stamp an existing database at the baseline (pre-Alembic installs) +alembic stamp 0001 +``` + +The Alembic configuration lives in `alembic.ini` and `alembic/`. It reads `DB_PATH` from the environment (default: `backend/data/portal.db`). + +--- + ## Architecture ``` diff --git a/backend/app.py b/backend/app.py index 9b0ac9c..bc94a41 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,18 +1,18 @@ """GraphQL Meter — FastAPI application entry point.""" -import os import sys from pathlib import Path # Ensure backend package is importable sys.path.insert(0, str(Path(__file__).parent.parent)) +from contextlib import asynccontextmanager + +import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from contextlib import asynccontextmanager -import uvicorn +from fastapi.staticfiles import StaticFiles from backend.config import get_settings from backend.core.plugin_registry import discover_plugins @@ -39,8 +39,8 @@ async def lifespan(app: FastAPI): # CORS app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=settings.cors_origins_list(), + allow_credentials=settings.CORS_ORIGINS.strip() != "*", allow_methods=["*"], allow_headers=["*"], ) @@ -61,8 +61,9 @@ def _seed_default_config(): import json import uuid from datetime import datetime, timezone - from backend.plugins.storage_plugin import get_db + from backend.plugins.graphql_health_plugin import HEALTH_SCHEMA + from backend.plugins.storage_plugin import get_db db = get_db() count = db.execute("SELECT COUNT(*) as cnt FROM test_configs").fetchone()["cnt"] diff --git a/backend/cli.py b/backend/cli.py index ebf9431..60cd485 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -1,16 +1,16 @@ """CLI entry point for graphql-meter.""" -import sys def main(): """Start the GraphQL Meter server.""" - from backend.vendor_manager import ensure_vendor_libs from backend.config import get_settings + from backend.vendor_manager import ensure_vendor_libs ensure_vendor_libs() import uvicorn + from backend.app import app settings = get_settings() diff --git a/backend/config.py b/backend/config.py index ee416d4..ab23ada 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,9 +1,8 @@ """Pydantic BaseSettings — all configuration from environment variables / .env file.""" -from pydantic_settings import BaseSettings -from pydantic import Field from pathlib import Path -import os + +from pydantic_settings import BaseSettings class Settings(BaseSettings): @@ -11,6 +10,7 @@ class Settings(BaseSettings): HOST: str = "0.0.0.0" PORT: int = 8899 DEBUG: bool = False + CORS_ORIGINS: str = "*" # Database DB_PATH: str = str(Path(__file__).parent / "data" / "portal.db") @@ -56,6 +56,12 @@ class Settings(BaseSettings): "case_sensitive": True, } + def cors_origins_list(self) -> list[str]: + raw = self.CORS_ORIGINS.strip() + if not raw or raw == "*": + return ["*"] + return [origin.strip() for origin in raw.split(",") if origin.strip()] + def get_settings() -> Settings: return Settings() diff --git a/backend/core/plugin_base.py b/backend/core/plugin_base.py index 21171f6..4dc713f 100644 --- a/backend/core/plugin_base.py +++ b/backend/core/plugin_base.py @@ -1,6 +1,7 @@ """Abstract base class for all plugins.""" from abc import ABC, abstractmethod + from fastapi import APIRouter diff --git a/backend/core/plugin_registry.py b/backend/core/plugin_registry.py index bb4bccb..17c7e2d 100644 --- a/backend/core/plugin_registry.py +++ b/backend/core/plugin_registry.py @@ -1,8 +1,6 @@ """Auto-discover and load plugins from backend/plugins/.""" import importlib -import os -import sys from pathlib import Path from typing import Dict diff --git a/backend/k6_engine/engine.py b/backend/k6_engine/engine.py index e5483a3..eb6c123 100644 --- a/backend/k6_engine/engine.py +++ b/backend/k6_engine/engine.py @@ -5,9 +5,7 @@ import math import os import signal -import shutil import subprocess -import sys import threading import time import uuid @@ -17,9 +15,9 @@ from typing import Dict from backend.config import get_settings -from backend.plugins.storage_plugin import get_db from backend.k6_engine.script_generator import generate_script from backend.k6_manager import ensure_k6 +from backend.plugins.storage_plugin import get_db _active_runs: Dict[str, dict] = {} _lock = threading.Lock() diff --git a/backend/k6_engine/script_generator.py b/backend/k6_engine/script_generator.py index 3aac697..c9319f0 100644 --- a/backend/k6_engine/script_generator.py +++ b/backend/k6_engine/script_generator.py @@ -143,7 +143,7 @@ def generate_script(config: dict) -> str: else: vars_lines.append(f' "{k}": {json.dumps(v)}') - lines.append(f' const variables = {{') + lines.append(' const variables = {') lines.append(",\n".join(vars_lines)) lines.append(' };') diff --git a/backend/k6_manager.py b/backend/k6_manager.py index 874bb75..e954753 100644 --- a/backend/k6_manager.py +++ b/backend/k6_manager.py @@ -1,7 +1,6 @@ """k6 binary dependency manager — detect or auto-download k6.""" import hashlib -import os import platform import shutil import stat diff --git a/backend/locust_engine/engine.py b/backend/locust_engine/engine.py index c59bad8..cfde82f 100644 --- a/backend/locust_engine/engine.py +++ b/backend/locust_engine/engine.py @@ -1,7 +1,6 @@ """Locust engine — lifecycle manager, subprocess spawn, file reader thread, stats polling.""" import json -import os import subprocess import sys import threading diff --git a/backend/locust_engine/token_manager.py b/backend/locust_engine/token_manager.py index c46485c..ca11179 100644 --- a/backend/locust_engine/token_manager.py +++ b/backend/locust_engine/token_manager.py @@ -2,6 +2,7 @@ import threading import time + import requests diff --git a/backend/locust_engine/worker.py b/backend/locust_engine/worker.py index f0b6580..f0dd8ea 100644 --- a/backend/locust_engine/worker.py +++ b/backend/locust_engine/worker.py @@ -10,7 +10,6 @@ import tempfile import time import traceback -from collections import defaultdict from pathlib import Path @@ -20,7 +19,6 @@ def run_worker(run_dir: str): import gevent from locust import events from locust.env import Environment - from locust.stats import stats_history, RequestStats run_path = Path(run_dir) config_path = run_path / "config.json" @@ -73,7 +71,7 @@ def _resolve_variables(vars_dict, r_val): return {k: _resolve_placeholder(v, r_val) for k, v in vars_dict.items()} # Build dynamic HttpUser - from locust import HttpUser, task, constant_pacing + from locust import HttpUser, constant_pacing task_funcs = {} range_counters = {} diff --git a/backend/models/test_config.py b/backend/models/test_config.py index 4488f7c..3338da8 100644 --- a/backend/models/test_config.py +++ b/backend/models/test_config.py @@ -1,7 +1,8 @@ """Pydantic models for test configuration.""" +from typing import Any, List, Optional + from pydantic import BaseModel, Field -from typing import List, Optional, Dict, Any class VariableConfig(BaseModel): diff --git a/backend/openapi.py b/backend/openapi.py new file mode 100644 index 0000000..69a6825 --- /dev/null +++ b/backend/openapi.py @@ -0,0 +1,25 @@ +"""Helpers for exporting the FastAPI OpenAPI schema.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from backend.app import app + + +def generate_openapi_spec() -> dict[str, Any]: + """Return the application's OpenAPI schema.""" + return app.openapi() + + +def export_openapi_spec(output_path: str | Path) -> Path: + """Write the OpenAPI schema to disk and return the output path.""" + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(generate_openapi_spec(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return path diff --git a/backend/plugins/auth_plugin.py b/backend/plugins/auth_plugin.py index ad90c08..952574a 100644 --- a/backend/plugins/auth_plugin.py +++ b/backend/plugins/auth_plugin.py @@ -10,8 +10,8 @@ from fastapi import HTTPException, Request from pydantic import BaseModel -from backend.core.plugin_base import PluginBase from backend.config import get_settings +from backend.core.plugin_base import PluginBase # Hardcoded users _USERS = { diff --git a/backend/plugins/authproviders_plugin.py b/backend/plugins/authproviders_plugin.py index 9c523a3..3566001 100644 --- a/backend/plugins/authproviders_plugin.py +++ b/backend/plugins/authproviders_plugin.py @@ -16,10 +16,10 @@ from fastapi import HTTPException, Request from pydantic import BaseModel -from backend.core.plugin_base import PluginBase from backend.config import get_settings -from backend.plugins.storage_plugin import get_db +from backend.core.plugin_base import PluginBase from backend.plugins.auth_plugin import require_auth, require_role +from backend.plugins.storage_plugin import get_db # ---------- Encryption helpers ---------- @@ -233,8 +233,8 @@ def _fetch_oauth2_token(config: dict, auth_type: str) -> Optional[dict]: def _generate_custom_jwt(config: dict) -> Optional[dict]: """Generate a JWT token using custom claims.""" - import hmac as hmac_mod import hashlib as hashlib_mod + import hmac as hmac_mod import time alg = config.get("algorithm", "HS256") diff --git a/backend/plugins/cleanup_plugin.py b/backend/plugins/cleanup_plugin.py index bddd7fb..cdb3836 100644 --- a/backend/plugins/cleanup_plugin.py +++ b/backend/plugins/cleanup_plugin.py @@ -1,24 +1,25 @@ """Cleanup plugin — execute delete mutations for test data cleanup.""" import json -import uuid import threading import time +import uuid from datetime import datetime, timezone import httpx from fastapi import HTTPException, Request from backend.core.plugin_base import PluginBase -from backend.plugins.storage_plugin import get_db from backend.plugins.auth_plugin import require_auth, require_role +from backend.plugins.storage_plugin import get_db _cleanup_threads = {} def _run_cleanup(job_id: str, run_id: str, host: str, graphql_path: str, operations: list, auth_header: str = ""): """Background thread that executes delete mutations.""" - import sqlite3, threading as th + import sqlite3 + from backend.config import get_settings settings = get_settings() diff --git a/backend/plugins/environments_plugin.py b/backend/plugins/environments_plugin.py index 1af975b..723ed66 100644 --- a/backend/plugins/environments_plugin.py +++ b/backend/plugins/environments_plugin.py @@ -8,15 +8,14 @@ import json import uuid from datetime import datetime, timezone +from typing import Optional -from fastapi import HTTPException, Request, UploadFile, File, Form +from fastapi import File, Form, HTTPException, Request, UploadFile from pydantic import BaseModel -from typing import Optional, List from backend.core.plugin_base import PluginBase -from backend.plugins.storage_plugin import get_db from backend.plugins.auth_plugin import require_auth, require_role - +from backend.plugins.storage_plugin import get_db # ---------- Encryption for cert passwords ---------- diff --git a/backend/plugins/graphql_health_plugin.py b/backend/plugins/graphql_health_plugin.py index 08fe820..efe580a 100644 --- a/backend/plugins/graphql_health_plugin.py +++ b/backend/plugins/graphql_health_plugin.py @@ -10,16 +10,15 @@ """ import json -import time import os +import time from datetime import datetime, timezone - -from backend import __version__ as _VERSION +from typing import Optional from fastapi import Request from pydantic import BaseModel -from typing import Optional +from backend import __version__ as _VERSION from backend.core.plugin_base import PluginBase # Simple in-memory state for the mock GraphQL server @@ -273,9 +272,10 @@ async def seed_default_config(request: Request): This gives new users a ready-to-run demo config. Skips creation if a config named 'Self Load Test' already exists. """ + import uuid as _uuid + from backend.plugins.auth_plugin import require_role from backend.plugins.storage_plugin import get_db - import uuid as _uuid user = require_role(request, "maintainer") db = get_db() diff --git a/backend/plugins/graphqlclient_plugin.py b/backend/plugins/graphqlclient_plugin.py index 18f0bef..bdc2923 100644 --- a/backend/plugins/graphqlclient_plugin.py +++ b/backend/plugins/graphqlclient_plugin.py @@ -7,12 +7,12 @@ - Saving and managing reusable GraphQL requests """ -import json -import uuid -import time import base64 -import tempfile +import json import os +import tempfile +import time +import uuid from datetime import datetime, timezone from typing import Optional @@ -21,9 +21,8 @@ from pydantic import BaseModel from backend.core.plugin_base import PluginBase -from backend.plugins.storage_plugin import get_db from backend.plugins.auth_plugin import require_auth, require_role - +from backend.plugins.storage_plugin import get_db # ---------- Request models ---------- diff --git a/backend/plugins/health_plugin.py b/backend/plugins/health_plugin.py index 9c6bb06..ecb9ee4 100644 --- a/backend/plugins/health_plugin.py +++ b/backend/plugins/health_plugin.py @@ -3,18 +3,17 @@ import os import time from datetime import datetime, timezone +from typing import Optional import psutil -from fastapi import HTTPException, Request +from fastapi import Request from pydantic import BaseModel -from typing import Optional -from backend.core.plugin_base import PluginBase +from backend import __version__ as _VERSION from backend.config import get_settings +from backend.core.plugin_base import PluginBase from backend.plugins.auth_plugin import require_auth, require_role -from backend import __version__ as _VERSION - _start_time = time.time() diff --git a/backend/plugins/k6_plugin.py b/backend/plugins/k6_plugin.py index 0236b63..dbc5673 100644 --- a/backend/plugins/k6_plugin.py +++ b/backend/plugins/k6_plugin.py @@ -1,12 +1,13 @@ """k6 plugin — start/stop/status for k6 test runs.""" +from typing import Optional + from fastapi import HTTPException, Request from pydantic import BaseModel -from typing import Optional from backend.core.plugin_base import PluginBase -from backend.plugins.auth_plugin import require_auth, require_role from backend.k6_engine import engine as k6_engine +from backend.plugins.auth_plugin import require_auth class StartK6RunRequest(BaseModel): @@ -86,8 +87,9 @@ async def run_status(run_id: str, request: Request): "total_request_bytes": stats.get("total_request_bytes", 0), }) - from backend.plugins.storage_plugin import get_db import json as _json + + from backend.plugins.storage_plugin import get_db db = get_db() run_row = db.execute( "SELECT name, started_at, config_snapshot, debug_mode, summary_json, status as db_status FROM test_runs WHERE id = ?", diff --git a/backend/plugins/locust_plugin.py b/backend/plugins/locust_plugin.py index c349898..6846f26 100644 --- a/backend/plugins/locust_plugin.py +++ b/backend/plugins/locust_plugin.py @@ -1,14 +1,14 @@ """Locust plugin — start/stop/status for Locust test runs.""" import json +from typing import Optional from fastapi import HTTPException, Request from pydantic import BaseModel -from typing import Optional from backend.core.plugin_base import PluginBase -from backend.plugins.auth_plugin import require_auth, require_role from backend.locust_engine import engine as locust_engine +from backend.plugins.auth_plugin import require_auth class StartRunRequest(BaseModel): diff --git a/backend/plugins/results_plugin.py b/backend/plugins/results_plugin.py index d33f0b6..f1ae74b 100644 --- a/backend/plugins/results_plugin.py +++ b/backend/plugins/results_plugin.py @@ -1,15 +1,14 @@ """Results plugin — run history, per-op stats, compare, trends, notes/tags.""" import json -from datetime import datetime, timezone +from typing import Optional -from fastapi import HTTPException, Request, Query +from fastapi import HTTPException, Query, Request from pydantic import BaseModel -from typing import Optional from backend.core.plugin_base import PluginBase -from backend.plugins.storage_plugin import get_db from backend.plugins.auth_plugin import require_auth, require_role +from backend.plugins.storage_plugin import get_db class NotesRequest(BaseModel): diff --git a/backend/plugins/schema_plugin.py b/backend/plugins/schema_plugin.py index c003bb6..371d42c 100644 --- a/backend/plugins/schema_plugin.py +++ b/backend/plugins/schema_plugin.py @@ -1,7 +1,7 @@ """Schema plugin — GraphQL AST parsing, operation extraction, test data generation.""" import re -from typing import List, Dict, Any, Optional +from typing import Any, Dict, Optional from fastapi import HTTPException from pydantic import BaseModel @@ -9,7 +9,7 @@ from backend.core.plugin_base import PluginBase try: - from graphql import parse as gql_parse, print_ast + from graphql import parse as gql_parse from graphql.language import ast as gql_ast HAS_GRAPHQL_CORE = True except ImportError: @@ -293,7 +293,7 @@ async def parse_schema(body: ParseRequest): if HAS_GRAPHQL_CORE: try: result = _extract_operations_ast(schema_text) - except Exception as e: + except Exception: parse_method = "regex" result = _extract_operations_regex(schema_text) else: diff --git a/backend/plugins/storage_plugin.py b/backend/plugins/storage_plugin.py index 2b7764b..0914b42 100644 --- a/backend/plugins/storage_plugin.py +++ b/backend/plugins/storage_plugin.py @@ -4,12 +4,12 @@ import threading from datetime import datetime, timezone from pathlib import Path -from typing import Optional, List, Dict, Any +from typing import Optional from fastapi import HTTPException -from backend.core.plugin_base import PluginBase from backend.config import get_settings +from backend.core.plugin_base import PluginBase _local = threading.local() _db_path: Optional[str] = None diff --git a/backend/plugins/testconfig_plugin.py b/backend/plugins/testconfig_plugin.py index a6b7b7a..4d9e5c9 100644 --- a/backend/plugins/testconfig_plugin.py +++ b/backend/plugins/testconfig_plugin.py @@ -3,14 +3,14 @@ import json import uuid from datetime import datetime, timezone +from typing import Optional from fastapi import HTTPException, Request from pydantic import BaseModel -from typing import Optional, List from backend.core.plugin_base import PluginBase -from backend.plugins.storage_plugin import get_db from backend.plugins.auth_plugin import require_auth, require_role +from backend.plugins.storage_plugin import get_db class TestConfigSaveRequest(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 4c1602f..d275bff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,12 @@ dependencies = [ [project.optional-dependencies] dev = [ + "alembic>=1.13", + "mypy>=1.10", + "pip-audit>=2.8.0", + "pre-commit>=4.2.0", "pytest>=8.0", - "httpx>=0.28.0", + "ruff>=0.11.13", ] [project.scripts] @@ -75,6 +79,33 @@ exclude = [ "backend/data/", ] +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +warn_unused_ignores = false + +# Modules being incrementally adopted — suppress until fixed +[[tool.mypy.overrides]] +module = [ + "backend.core.cache", + "backend.k6_engine.engine", + "backend.k6_manager", + "backend.locust_engine.token_manager", + "backend.plugins.authproviders_plugin", + "backend.plugins.graphqlclient_plugin", + "backend.plugins.health_plugin", + "backend.plugins.results_plugin", + "backend.plugins.schema_plugin", +] +ignore_errors = true + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E9", "F"] +ignore = ["F841"] + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..bf985c7 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,93 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +bidict==0.23.1 +blinker==1.9.0 +boolean.py==5.0 +brotli==1.2.0 +build==1.4.3 +CacheControl==0.14.4 +cachetools==7.0.5 +certifi==2026.2.25 +cffi==2.0.0 +cfgv==3.5.0 +charset-normalizer==3.4.7 +click==8.3.2 +ConfigArgParse==1.7.5 +cryptography==46.0.6 +cyclonedx-python-lib==11.7.0 +defusedxml==0.7.1 +distlib==0.4.0 +esprima==4.0.1 +fastapi==0.135.3 +filelock==3.28.0 +Flask==3.1.3 +flask-cors==6.0.2 +Flask-Login==0.6.3 +gevent==25.9.1 +geventhttpclient==2.3.9 +graphql-core==3.2.8 +-e git+https://github.com/vanditsramblings/graphql-meter.git@887e1bc753b5f80c0066632e32145dcb145e68ca#egg=graphql_meter +greenlet==3.3.2 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +identify==2.6.18 +idna==3.11 +iniconfig==2.3.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +license-expression==30.4.4 +locust==2.43.4 +markdown-it-py==4.0.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +msgpack==1.1.2 +nodeenv==1.10.0 +packageurl-python==0.17.6 +packaging==26.0 +pip-api==0.0.34 +pip-requirements-parser==32.0.1 +pip_audit==2.10.0 +platformdirs==4.9.6 +pluggy==1.6.0 +pre_commit==4.5.1 +psutil==7.2.2 +py-serializable==2.1.0 +pycparser==3.0 +pydantic==2.12.5 +pydantic-settings==2.13.1 +pydantic_core==2.41.5 +Pygments==2.20.0 +pyparsing==3.3.2 +pyproject_hooks==1.2.0 +pytest==9.0.2 +python-discovery==1.2.2 +python-dotenv==1.2.2 +python-engineio==4.13.1 +python-multipart==0.0.24 +python-socketio==5.16.1 +PyYAML==6.0.3 +pyzmq==27.1.0 +requests==2.33.1 +rich==15.0.0 +ruff==0.15.10 +simple-websocket==1.1.0 +sortedcontainers==2.4.0 +starlette==1.0.0 +tomli==2.4.1 +tomli_w==1.2.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +uvicorn==0.44.0 +uvloop==0.22.1 +virtualenv==21.2.4 +watchfiles==1.1.1 +websocket-client==1.9.0 +websockets==16.0 +Werkzeug==3.1.8 +wsproto==1.3.2 +zope.event==6.1 +zope.interface==8.2 diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..3138deb --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Export the FastAPI OpenAPI schema to a JSON file.""" + +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from backend.openapi import export_openapi_spec + + +def main() -> None: + output_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "reference" / "openapi.json" + written = export_openapi_spec(output_path) + print(f"OpenAPI spec written to {written}") + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index fb4615c..e91cf85 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,6 @@ """Shared test fixtures for backend unit tests.""" -import os import sqlite3 -import tempfile import time import pytest @@ -66,8 +64,8 @@ def db(): @pytest.fixture def admin_token(): """Generate a valid admin JWT token for authenticated requests.""" - from backend.plugins.auth_plugin import _create_jwt from backend.config import get_settings + from backend.plugins.auth_plugin import _create_jwt settings = get_settings() payload = { "sub": "admin", @@ -82,8 +80,8 @@ def admin_token(): @pytest.fixture def maintainer_token(): """Generate a valid maintainer JWT token.""" - from backend.plugins.auth_plugin import _create_jwt from backend.config import get_settings + from backend.plugins.auth_plugin import _create_jwt settings = get_settings() payload = { "sub": "maintainer", @@ -98,8 +96,8 @@ def maintainer_token(): @pytest.fixture def reader_token(): """Generate a valid reader JWT token.""" - from backend.plugins.auth_plugin import _create_jwt from backend.config import get_settings + from backend.plugins.auth_plugin import _create_jwt settings = get_settings() payload = { "sub": "reader", diff --git a/tests/test_auth.py b/tests/test_auth.py index 3a16b77..395f0ae 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -3,22 +3,17 @@ import time import pytest +from fastapi import FastAPI from fastapi.testclient import TestClient -from fastapi import FastAPI, Request +from backend.config import get_settings from backend.plugins.auth_plugin import ( + AuthPlugin, _create_jwt, _decode_jwt, - get_current_user, - require_auth, - require_role, - has_role, get_flags_for_role, - AuthPlugin, + has_role, ) -from backend.config import get_settings -from tests.conftest import auth_headers - # ---------- JWT helper tests ---------- diff --git a/tests/test_authproviders.py b/tests/test_authproviders.py index e292e90..5b32bd2 100644 --- a/tests/test_authproviders.py +++ b/tests/test_authproviders.py @@ -1,25 +1,22 @@ """Tests for the auth providers plugin — encryption, CRUD, token caching.""" -import json -import time import pytest -from fastapi.testclient import TestClient from fastapi import FastAPI +from fastapi.testclient import TestClient from backend.plugins.authproviders_plugin import ( - _encrypt, + AUTH_TYPE_FIELDS, + AuthProvidersPlugin, _decrypt, + _encrypt, _mask, _mask_config, + _token_cache, + clear_token_cache, get_auth_header, get_cached_auth_header, - clear_token_cache, - AUTH_TYPE_FIELDS, - AuthProvidersPlugin, - _token_cache, ) -from backend.plugins.storage_plugin import get_db from tests.conftest import auth_headers diff --git a/tests/test_environments.py b/tests/test_environments.py index fbcd007..6581b9d 100644 --- a/tests/test_environments.py +++ b/tests/test_environments.py @@ -1,20 +1,17 @@ """Tests for the environments plugin — CRUD, TLS config, cert types.""" -import json import uuid import pytest -from fastapi.testclient import TestClient from fastapi import FastAPI +from fastapi.testclient import TestClient from backend.plugins.environments_plugin import ( - EnvironmentsPlugin, CERT_TYPES, - TLS_MODES, - _encrypt_cert_password, + EnvironmentsPlugin, _decrypt_cert_password, + _encrypt_cert_password, ) -from backend.plugins.storage_plugin import get_db from tests.conftest import auth_headers diff --git a/tests/test_graphqlclient.py b/tests/test_graphqlclient.py index 818ceee..b3166a0 100644 --- a/tests/test_graphqlclient.py +++ b/tests/test_graphqlclient.py @@ -4,16 +4,15 @@ import uuid import pytest -from fastapi.testclient import TestClient from fastapi import FastAPI +from fastapi.testclient import TestClient from backend.plugins.graphqlclient_plugin import ( + INTROSPECTION_QUERY, GraphQLClientPlugin, - _resolve_target, _format_type_ref, - INTROSPECTION_QUERY, + _resolve_target, ) -from backend.plugins.storage_plugin import get_db from tests.conftest import auth_headers diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 0000000..d66514a --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,16 @@ +"""Tests for OpenAPI spec export.""" + +import json + +from backend.openapi import export_openapi_spec + + +def test_export_openapi_spec_writes_json(tmp_path): + output_path = tmp_path / "openapi.json" + + export_openapi_spec(output_path) + + data = json.loads(output_path.read_text()) + assert data["info"]["title"] == "GraphQL Meter" + assert data["info"]["version"] + assert "/api/health/status" in data["paths"] diff --git a/tests/test_storage.py b/tests/test_storage.py index 0a4b305..b7b86a1 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,15 +1,12 @@ """Tests for the storage plugin — database init, migrations, metadata CRUD.""" import sqlite3 -from datetime import datetime, timezone import pytest -from fastapi.testclient import TestClient from fastapi import FastAPI +from fastapi.testclient import TestClient -from backend.plugins.storage_plugin import get_db, _init_tables, _migrate_schema -from tests.conftest import auth_headers - +from backend.plugins.storage_plugin import _migrate_schema # ---------- Database initialization tests ---------- diff --git a/tests/test_testconfig.py b/tests/test_testconfig.py index b6520f2..3d79807 100644 --- a/tests/test_testconfig.py +++ b/tests/test_testconfig.py @@ -1,14 +1,12 @@ """Tests for the test config plugin — CRUD, TPS% validation.""" -import json import uuid import pytest -from fastapi.testclient import TestClient from fastapi import FastAPI +from fastapi.testclient import TestClient from backend.plugins.testconfig_plugin import TestConfigPlugin -from backend.plugins.storage_plugin import get_db from tests.conftest import auth_headers