Initial curated RepoPulse source from 2524ccc3c1c27739996dccb2b7992989f88cd26d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:52 +02:00
commit 0adf8ef5d9
90 files changed
+14315

No files matched your search

+491
View File
@@ -0,0 +1,491 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import os
from contextlib import asynccontextmanager
from pathlib import Path
from threading import BoundedSemaphore, Lock
from fastapi import FastAPI, HTTPException, Query, Request, Response, status
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from .acquisition import GitAcquirer
from .gitea import GiteaClient
from .jobs import ScanJobRunner
from .models import (
RepositoryDetail,
RepositoryScanRequest,
RepositorySummary,
RepositorySyncResult,
ScanComparison,
ScanJob,
ScanReport,
ScanRequest,
ScanSummary,
ScanTrigger,
TrendPoint,
WebhookResponse,
)
from .reporting import content_disposition, render_html_report, safe_report_filename
from .scanner import scan_repository
from .store import MaintenanceModeError, QueueFullError, ScanStore
VERSION = "1.5.0"
store = ScanStore()
SCAN_ROOT = os.getenv("REPOPULSE_SCAN_ROOT")
def _positive_int_env(name: str, default: int) -> int:
try:
return max(1, int(os.getenv(name, str(default))))
except ValueError:
return default
# The durable queue has one real worker. Keep direct legacy path scans at the
# same capacity rather than advertising concurrency that cannot be delivered.
REQUESTED_MAX_CONCURRENT_SCANS = _positive_int_env("REPOPULSE_MAX_CONCURRENT_SCANS", 1)
MAX_CONCURRENT_SCANS = 1
SCAN_SLOTS = BoundedSemaphore(MAX_CONCURRENT_SCANS)
gitea_client = GiteaClient()
repository_acquirer = GitAcquirer()
job_runner: ScanJobRunner | None = None
_api_mutation_lock = Lock()
_MAINTENANCE_ENTER_PATH = "/api/v1/operator/maintenance/enter"
_MAINTENANCE_EXIT_PATH = "/api/v1/operator/maintenance/exit"
def _get_runner() -> ScanJobRunner:
global job_runner
if job_runner is None or job_runner.store is not store:
job_runner = ScanJobRunner(
store,
gitea=gitea_client,
acquirer=repository_acquirer,
)
return job_runner
@asynccontextmanager
async def lifespan(_app: FastAPI):
runner = _get_runner()
runner.start()
try:
yield
finally:
runner.stop()
app = FastAPI(title="RepoPulse API", version=VERSION, lifespan=lifespan)
@app.middleware("http")
async def maintenance_mutation_gate(request: Request, call_next):
"""Serialize external API writes with maintenance transitions and reject writes while paused."""
if not request.url.path.startswith("/api/v1/") or request.method in {"GET", "HEAD", "OPTIONS"}:
return await call_next(request)
if request.url.path in {_MAINTENANCE_ENTER_PATH, _MAINTENANCE_EXIT_PATH}:
return await call_next(request)
await run_in_threadpool(_api_mutation_lock.acquire)
try:
if store.maintenance_status()["enabled"]:
return JSONResponse(
status_code=status.HTTP_423_LOCKED,
content={"detail": "RepoPulse is in maintenance mode"},
headers={"Retry-After": "30"},
)
return await call_next(request)
finally:
_api_mutation_lock.release()
def _maintenance_snapshot() -> dict[str, object]:
mode = store.maintenance_status()
stats = store.queue_stats()
queued = int(stats["queued"])
active = int(stats["active"])
return {
**mode,
"queue_depth": queued,
"active_jobs": active,
"active_drained": active == 0,
"drained": queued == 0 and active == 0,
}
def _require_mutation_authorization(request: Request, *, strict: bool = False) -> None:
"""Use an operator token when configured; deployment perimeter remains explicit otherwise."""
token = os.getenv("REPOPULSE_OPERATOR_TOKEN", "")
if strict:
token = os.getenv("REPOPULSE_MAINTENANCE_TOKEN", "") or token
if not token:
if strict:
raise HTTPException(status_code=503, detail="Operator token is not configured")
return
supplied = request.headers.get("x-repopulse-token", "")
if not hmac.compare_digest(supplied, token):
raise HTTPException(status_code=401, detail="Operator authorization is required")
def _resolve_scan_path(raw_path: str) -> Path:
supplied = Path(raw_path).expanduser()
if SCAN_ROOT:
root = Path(SCAN_ROOT).expanduser().resolve()
candidate = (root / supplied).resolve() if not supplied.is_absolute() else supplied.resolve()
if candidate != root and root not in candidate.parents:
raise ValueError(f"Path must be inside configured scan root {root}")
return candidate
return supplied.resolve()
@app.get("/health")
def health() -> dict[str, str]:
recovery = store.database_recovery_status()
status = "degraded" if recovery.get("status") == "degraded_recovered" else "ok"
return {"status": status}
@app.get("/api/v1/meta")
def meta() -> dict[str, object]:
stats = store.queue_stats()
repository_states = store.repository_state_counts()
last_sync = store.get_state("last_gitea_sync")
parsed_sync: object = None
if last_sync:
try:
parsed_sync = json.loads(last_sync)
except json.JSONDecodeError:
parsed_sync = last_sync
return {
"name": "RepoPulse",
"version": VERSION,
"stage": "v1.5.0",
"scan_root_restricted": bool(SCAN_ROOT),
"scan_root": SCAN_ROOT,
"max_concurrent_scans": MAX_CONCURRENT_SCANS,
"requested_max_concurrent_scans": REQUESTED_MAX_CONCURRENT_SCANS,
"repository_count": store.repository_count(),
"repository_states": repository_states,
"current_repositories": repository_states.get("current", 0),
"stale_repositories": repository_states.get("stale", 0),
"queue_depth": stats["queued"],
"active_jobs": stats["active"],
"completed_jobs": stats["completed"],
"failed_jobs": stats["failed"],
"worker_heartbeat": stats["worker_heartbeat"],
"gitea": {
"configured": gitea_client.configured,
"url": gitea_client.base_url or None,
"last_sync": parsed_sync,
},
"storage": store.storage_stats(),
"database_recovery": store.database_recovery_status(),
"maintenance": _maintenance_snapshot(),
"mutation_authentication": "operator_token" if os.getenv("REPOPULSE_OPERATOR_TOKEN") else "perimeter_required",
}
@app.get("/api/v1/operator/maintenance")
def get_maintenance_status(request: Request) -> dict[str, object]:
_require_mutation_authorization(request, strict=True)
return _maintenance_snapshot()
@app.post("/api/v1/operator/maintenance/enter")
def enter_maintenance(request: Request) -> dict[str, object]:
_require_mutation_authorization(request, strict=True)
with _api_mutation_lock:
if store.maintenance_status()["enabled"]:
raise HTTPException(status_code=423, detail="RepoPulse is already in maintenance mode")
_get_runner().set_maintenance_mode(True)
return _maintenance_snapshot()
@app.post("/api/v1/operator/maintenance/exit")
def exit_maintenance(request: Request) -> dict[str, object]:
_require_mutation_authorization(request, strict=True)
with _api_mutation_lock:
_get_runner().set_maintenance_mode(False)
return _maintenance_snapshot()
@app.post("/api/v1/scans", response_model=ScanReport, status_code=status.HTTP_201_CREATED)
async def create_scan(payload: ScanRequest, request: Request) -> ScanReport:
_require_mutation_authorization(request)
try:
repository_path = _resolve_scan_path(payload.path)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if not SCAN_SLOTS.acquire(blocking=False):
raise HTTPException(
status_code=429,
detail="Repository scan capacity is currently in use; retry after the active scan completes",
headers={"Retry-After": "2"},
)
try:
report = await asyncio.to_thread(scan_repository, str(repository_path))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail="Repository path cannot be read") from exc
except OSError as exc:
raise HTTPException(status_code=422, detail="Repository path cannot be scanned") from exc
finally:
SCAN_SLOTS.release()
return store.save(report)
@app.get("/api/v1/repositories", response_model=list[RepositorySummary])
def list_repositories(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0, le=100_000),
) -> list[RepositorySummary]:
return store.list_repositories(limit, offset)
@app.post("/api/v1/repositories/sync", response_model=RepositorySyncResult)
async def sync_repositories(request: Request) -> RepositorySyncResult:
_require_mutation_authorization(request)
if not gitea_client.configured:
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
try:
result = await asyncio.to_thread(_get_runner().sync_now)
except MaintenanceModeError as exc:
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
if result.errors and result.branch_failures == 0:
raise HTTPException(status_code=502, detail=result.errors[0])
return result
@app.get("/api/v1/repositories/{repository_id}", response_model=RepositoryDetail)
def get_repository(repository_id: str) -> RepositoryDetail:
repository = store.get_repository_detail(repository_id)
if repository is None:
raise HTTPException(status_code=404, detail="Repository not found")
return repository
@app.get("/api/v1/repositories/{repository_id}/scans", response_model=list[ScanSummary])
def list_repository_scans(
repository_id: str,
limit: int = Query(default=50, ge=1, le=200),
) -> list[ScanSummary]:
if store.get_repository(repository_id) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return store.repository_scans(repository_id, limit)
@app.get("/api/v1/repositories/{repository_id}/trend", response_model=list[TrendPoint])
def repository_trend(
repository_id: str,
limit: int = Query(default=50, ge=1, le=200),
) -> list[TrendPoint]:
if store.get_repository(repository_id) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return store.trend(repository_id, limit)
@app.post("/api/v1/repositories/{repository_id}/scans", response_model=ScanJob, status_code=202)
async def create_repository_scan(
request: Request,
repository_id: str,
payload: RepositoryScanRequest | None = None,
) -> ScanJob:
_require_mutation_authorization(request)
repository = store.get_repository(repository_id)
if repository is None:
raise HTTPException(status_code=404, detail="Repository not found")
if repository.archived:
raise HTTPException(status_code=409, detail="Archived repositories are not scanned")
if not repository.available:
raise HTTPException(status_code=409, detail="Repository is no longer available to the Gitea connector")
if repository.provider == "gitea" and not gitea_client.configured:
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
try:
return _get_runner().enqueue(repository, force=bool(payload and payload.force))
except MaintenanceModeError as exc:
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
except QueueFullError as exc:
raise HTTPException(
status_code=429,
detail=str(exc),
headers={"Retry-After": "5"},
) from exc
@app.get("/api/v1/scan-jobs/{job_id}", response_model=ScanJob)
def get_scan_job(job_id: str) -> ScanJob:
job = store.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Scan job not found")
return job
@app.get("/api/v1/repositories/{repository_id}/scans/{scan_id}", response_model=ScanReport)
def get_repository_scan(repository_id: str, scan_id: str) -> ScanReport:
report = store.get(scan_id)
if report is None or report.source.repository_id != repository_id:
raise HTTPException(status_code=404, detail="Repository scan not found")
return report
@app.post("/api/v1/webhooks/gitea", response_model=WebhookResponse)
async def gitea_webhook(request: Request) -> WebhookResponse:
secret = os.getenv("REPOPULSE_GITEA_WEBHOOK_SECRET", "")
if not secret:
raise HTTPException(status_code=404, detail="Gitea webhook integration is not configured")
body = await request.body()
if len(body) > 2_000_000:
raise HTTPException(status_code=413, detail="Webhook payload is too large")
signature = request.headers.get("x-gitea-signature", "").removeprefix("sha256=")
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
if not signature or not hmac.compare_digest(signature.casefold(), expected.casefold()):
raise HTTPException(status_code=401, detail="Invalid webhook signature")
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise HTTPException(status_code=400, detail="Webhook payload is invalid JSON") from exc
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Webhook payload must be an object")
repository_payload = payload.get("repository")
if not isinstance(repository_payload, dict):
raise HTTPException(status_code=400, detail="Webhook repository is missing")
raw_id = repository_payload.get("id")
repository_id = f"gitea:{raw_id}" if str(raw_id).isdigit() else None
repository = store.get_repository(repository_id) if repository_id else None
if repository is None:
return WebhookResponse(
accepted=False,
message="Repository is not present in the synchronized catalog",
)
event = request.headers.get("x-gitea-event", "push").casefold()
if event != "push":
return WebhookResponse(
accepted=True,
repository_id=repository.id,
message="Non-push webhook acknowledged without scheduling a scan",
)
ref = payload.get("ref")
if ref != f"refs/heads/{repository.default_branch}":
return WebhookResponse(
accepted=True,
repository_id=repository.id,
message="Non-default branch push acknowledged without scheduling a scan",
)
after = payload.get("after")
if (
not isinstance(after, str)
or not 7 <= len(after) <= 64
or not all(char in "0123456789abcdefABCDEF" for char in after)
or not set(after) - {"0"}
):
return WebhookResponse(
accepted=True,
repository_id=repository.id,
message="Push acknowledged without a usable commit revision",
)
if not gitea_client.configured:
raise HTTPException(status_code=503, detail="Gitea connector is not configured")
updated = repository.model_copy(update={"latest_revision": after, "sync_error": None})
store.upsert_repository(updated)
try:
job = _get_runner().enqueue(updated, trigger=ScanTrigger.WEBHOOK)
except MaintenanceModeError as exc:
raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc
except QueueFullError as exc:
raise HTTPException(status_code=429, detail=str(exc), headers={"Retry-After": "5"}) from exc
return WebhookResponse(
accepted=True,
repository_id=repository.id,
job_id=job.id,
message="Push accepted and scan scheduled",
)
@app.post("/api/v1/operator/database-recovery/acknowledge", status_code=status.HTTP_204_NO_CONTENT)
def acknowledge_database_recovery(request: Request) -> Response:
_require_mutation_authorization(request, strict=True)
store.acknowledge_database_recovery()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.get("/api/v1/scans", response_model=list[ScanReport])
def list_scans(limit: int = Query(default=20, ge=1, le=100)) -> list[ScanReport]:
return store.recent(limit)
@app.get("/api/v1/scans/{scan_id}", response_model=ScanReport)
def get_scan(scan_id: str) -> ScanReport:
report = store.get(scan_id)
if report is None:
raise HTTPException(status_code=404, detail="Scan not found")
return report
@app.get("/api/v1/scans/{scan_id}/comparison", response_model=ScanComparison)
def get_scan_comparison(scan_id: str) -> ScanComparison:
report = store.get(scan_id)
if report is None:
raise HTTPException(status_code=404, detail="Scan not found")
return store.compare(report)
@app.get("/api/v1/scans/{scan_id}/delta")
def get_scan_delta(scan_id: str) -> dict[str, int | None]:
report = store.get(scan_id)
if report is None:
raise HTTPException(status_code=404, detail="Scan not found")
previous = store.previous_for_repository(
report.source.repository_id or report.repository_path,
report.id,
)
return {
"previous_health_score": previous.health_score if previous else None,
"health_score_delta": report.health_score - previous.health_score if previous else None,
"previous_finding_count": len(previous.findings) if previous else None,
"finding_count_delta": len(report.findings) - len(previous.findings) if previous else None,
}
@app.get("/api/v1/trend", response_model=list[TrendPoint])
def trend(path: str, limit: int = Query(default=30, ge=1, le=100)) -> list[TrendPoint]:
try:
resolved = _resolve_scan_path(path)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return store.trend(str(resolved), limit)
@app.get("/api/v1/scans/{scan_id}/export")
def export_scan(scan_id: str, format: str = Query(default="html", pattern="^(html|json)$")) -> Response:
report = store.get(scan_id)
if report is None:
raise HTTPException(status_code=404, detail="Scan not found")
if format == "json":
filename = safe_report_filename(report.repository_name, "json")
return Response(
report.model_dump_json(indent=2),
media_type="application/json",
headers={"Content-Disposition": content_disposition(filename)},
)
filename = safe_report_filename(report.repository_name, "html")
return Response(
render_html_report(report),
media_type="text/html",
headers={"Content-Disposition": content_disposition(filename)},
)
static_dir = Path(os.getenv("REPOPULSE_STATIC_DIR", "/app/static"))
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend")