Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.git
.venv
.pytest_cache
.ruff_cache
**/__pycache__
ui/node_modules
ui/dist
deployment/dev
docs_jupyter
local_cache
*.h5
4 changes: 3 additions & 1 deletion .github/workflows/deploy-api-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ jobs:
REPOSITORY: dev1-bedhost
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG -f deployment/Dockerfiles/${{ env.server }}.Dockerfile .
docker build -t bedhost-base:$IMAGE_TAG -f dev.Dockerfile .
docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG --build-arg BASE_IMAGE=bedhost-base:$IMAGE_TAG -f deployment/Dockerfiles/${{ env.server }}.Dockerfile .
./tests/scripts/container-smoke.sh $REGISTRY/$REPOSITORY:$IMAGE_TAG
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
echo "image=$REGISTRY/$REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ jobs:
REPOSITORY: bedhost
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG -f deployment/Dockerfiles/${{ env.server }}.Dockerfile .
docker build -t bedhost-base:$IMAGE_TAG -f Dockerfile .
docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG --build-arg BASE_IMAGE=bedhost-base:$IMAGE_TAG -f deployment/Dockerfiles/${{ env.server }}.Dockerfile .
./tests/scripts/container-smoke.sh $REGISTRY/$REPOSITORY:$IMAGE_TAG
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
echo "image=$REGISTRY/$REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

Expand Down
59 changes: 29 additions & 30 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,42 +1,41 @@
FROM python:3.13-slim
LABEL authors="Oleksandr Khoroshevskyi, Nathan Sheffield"

RUN apt-get update
RUN apt-get install -y gcc
RUN apt-get install -y libpq-dev
RUN apt-get install -y --no-install-recommends git
# gcc/build-essential/python3-dev: hnswlib build (geniml); libpq-dev: postgres; git: git deps
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc build-essential python3-dev libpq-dev git \
&& rm -rf /var/lib/apt/lists/*

# RUN apt-get update -y && \
# apt-get install -y libpq-dev && \ # for postgres backend
# apt-get install -y gcc && \
# apt-get install -y wget && \
# apt-get install -y libcurl4 && \
# cpp compiler required for hsnwlib
# UCSC bigBedToBed for the /v1/bed/{id}/regions route (needs only libc)
ADD --chmod=755 https://hgdownload.soe.ucsc.edu/admin/exe/linux.x86_64/bigBedToBed /usr/local/bin/bigBedToBed

EXPOSE 80
EXPOSE 6333
ENV HNSWLIB_NO_NATIVE=1 \
PYTHONDONTWRITEBYTECODE=1

WORKDIR /app
COPY . /app

RUN python -m pip install --upgrade pip

# Need this command due to geniml dependency on hnswlib
ENV HNSWLIB_NO_NATIVE=1
RUN apt-get install -y python3-dev
RUN apt-get install -y build-essential
RUN pip install --no-cache-dir --upgrade pip uv

RUN pip install uv

# Install CPU-only pytorch, eliminating huge nvidia dependencies
#pip install torch==2.3.1+cpu -f https://download.pytorch.org/whl/torch_stable.html
RUN uv pip install torch --index-url https://download.pytorch.org/whl/cpu --system
# RUN uv pip install https://github.com/pepkit/pipestat/archive/refs/heads/dev.zip --system
WORKDIR /app
# Install deps before copying the full source so the dep layer caches
COPY pyproject.toml /app/pyproject.toml

# Install dependencies only (from pyproject.toml), NOT bedhost itself: the app
# runs from the /app source tree copied above (uvicorn bedhost.main:app), and
# runs from the /app source tree copied below (uvicorn bedhost.main:app), and
# the version is read from bedhost/_version.py, so bedhost is intentionally not
# installed as a distribution here.
RUN uv pip install -r pyproject.toml --no-cache-dir --system
# --torch-backend cpu: without it, torch resolves to the CUDA build and pulls
# ~3 GB of nvidia-* libraries the server never uses.
RUN uv pip install -r pyproject.toml --torch-backend cpu --system --no-cache-dir --compile-bytecode

CMD ["uvicorn", "bedhost.main:app", "--host", "0.0.0.0", "--port", "80"]
COPY . /app
RUN python -m compileall -q /app/bedhost

# Unprivileged runtime user. /app and site-packages stay root-owned (read-only to the app).
RUN useradd --system --uid 10001 --user-group --create-home --home-dir /home/bedhost --shell /usr/sbin/nologin bedhost
ENV HOME=/home/bedhost \
HF_HOME=/home/bedhost/.cache/huggingface \
FASTEMBED_CACHE_PATH=/home/bedhost/.cache/fastembed \
NUMBA_CACHE_DIR=/home/bedhost/.cache/numba
USER bedhost

EXPOSE 8000
CMD ["uvicorn", "bedhost.main:app", "--host", "0.0.0.0", "--port", "8000"]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,4 @@ RUN_INTEGRATION_TESTS=true pytest tests/integration/ tests/api/
Integration tests require network access on first boot (bbconf downloads a
licenses CSV from GitHub and may pull HuggingFace models unless
`BEDHOST_INIT_ML=false` is set; `test-integration.sh` sets it).
`BEDHOST_LOG_LEVEL` (default `INFO`) sets the log level for bedhost, bbconf, and geniml.
2 changes: 1 addition & 1 deletion bedhost/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.13.0"
__version__ = "0.14.0"
22 changes: 22 additions & 0 deletions bedhost/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,30 @@


MAX_FILE_SIZE: int = 1024 * 1024 * 20
# Decompressed size cap for gzip uploads, so a small gzip cannot expand without bound.
MAX_UNCOMPRESSED_SIZE: int = 1024 * 1024 * 200
MAX_REGION_NUMBER: int = 5000000
MIN_REGION_WIDTH: int = 10
# Chunk size used when copying an uploaded file to disk.
UPLOAD_CHUNK_SIZE: int = 1024 * 1024
# Slack for multipart boundaries and headers in the Content-Length pre-check.
MULTIPART_OVERHEAD: int = 64 * 1024
# Routes that accept BED file uploads (guarded by upload_size_guard).
UPLOAD_ROUTES: frozenset[str] = frozenset(
{"/v1/bed/embed", "/v1/bed/umap", "/v1/bed/search/bed"}
)

# Upper bounds for request inputs (security review finding O1).
MAX_LIST_LIMIT: int = 10000 # paged list endpoints
MAX_SEARCH_LIMIT: int = 100 # search + neighbour endpoints (UI max is 100)
# hg38 with alts/decoys/unplaced is under 3,500 contigs (hs38DH ~3,366);
# mm10/mm39 are under 100. The UI only sends chromosomes that actually
# appear in the uploaded BED file, so 10,000 is a generous ceiling that
# still bounds the work.
MAX_CHROM_ENTRIES: int = 10000 # /analyze-genome dict size
MAX_CHROM_NAME_LENGTH: int = 256 # /analyze-genome dict key length
BIGBED_TIMEOUT_SECONDS: int = 60 # /regions bigBedToBed wall-clock cap
MAX_REGIONS_OUTPUT_BYTES: int = 1024 * 1024 * 20 # /regions response size cap

# Public CDN base for bulk metadata export artifacts. These live directly on the
# storage CDN (Backblaze B2 fronted by Cloudflare), NOT behind the API's
Expand Down
18 changes: 16 additions & 2 deletions bedhost/data_models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from enum import Enum
from typing import Annotated

from fastapi import Path
from pydantic import BaseModel
from pydantic import BaseModel, Field, StringConstraints

from .const import MAX_CHROM_ENTRIES, MAX_CHROM_NAME_LENGTH

RemoteClassEnum = Enum(
"RemoteClassEnum",
Expand Down Expand Up @@ -83,5 +86,16 @@ class CreateBEDsetRequest(BaseModel):
registry_path: str


ChromName = Annotated[
str, StringConstraints(min_length=1, max_length=MAX_CHROM_NAME_LENGTH)
]
ChromLength = Annotated[int, Field(ge=0)]


class ChromLengthUploadModel(BaseModel):
bed_file: dict[str, int]
bed_file: dict[ChromName, ChromLength] = Field(
...,
min_length=1,
max_length=MAX_CHROM_ENTRIES,
description=f"Map of chromosome name to length (1-{MAX_CHROM_ENTRIES} entries, lengths >= 0)",
)
73 changes: 71 additions & 2 deletions bedhost/helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import datetime
import inspect
import os
import subprocess
import tempfile
import threading
from collections.abc import Callable
from functools import wraps
from typing import Literal
Expand Down Expand Up @@ -46,6 +49,72 @@ def serve_file(
raise FileNotFoundError(msg)


class RegionOutputTooLarge(Exception):
"""bigBedToBed produced more output than the allowed ceiling."""


def run_bigbed_to_bed(cmd: list[str], timeout: int, max_bytes: int) -> str:
"""Run bigBedToBed, wait for it, return only the first 3 BED columns.

Output is read line by line and trimmed as it arrives, so memory is bounded
by ``max_bytes`` rather than by the size of the bigBed file.

Args:
cmd: Full argv for the ``bigBedToBed`` invocation (no shell).
timeout: Wall-clock timeout in seconds.
max_bytes: Ceiling on the trimmed output size.

Returns:
The first three tab-separated columns of each output line,
newline-terminated. Empty string if there is no output.

Raises:
FileNotFoundError: If the binary is missing.
RegionOutputTooLarge: If the output exceeds ``max_bytes``. The child is
killed before this is raised.
subprocess.TimeoutExpired: If the process exceeds ``timeout``. The
child is killed before this is raised.
subprocess.CalledProcessError: If the process exits non-zero.
"""
timed_out = threading.Event()
rows: list[str] = []
total = 0
# stderr goes to a file so a chatty child can never block on a full pipe.
with (
tempfile.TemporaryFile() as err,
subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=err, text=True) as proc,
):

def _kill_on_timeout():
timed_out.set()
proc.kill()

timer = threading.Timer(timeout, _kill_on_timeout)
timer.start()
try:
for line in proc.stdout:
row = "\t".join(line.rstrip("\n").split("\t", 3)[:3]) + "\n"
total += len(row)
if total > max_bytes:
proc.kill()
raise RegionOutputTooLarge(
f"bigBedToBed output exceeded {max_bytes} bytes"
)
rows.append(row)
proc.wait()
finally:
timer.cancel()

if timed_out.is_set():
raise subprocess.TimeoutExpired(cmd, timeout)
if proc.returncode != 0:
err.seek(0)
raise subprocess.CalledProcessError(
proc.returncode, cmd, stderr=err.read().decode(errors="replace")
)
return "".join(rows)


def build_exports_url(file_path: str) -> str:
"""
Build a public HTTPS CDN URL for an export / analysis-file S3 key.
Expand Down Expand Up @@ -152,8 +221,8 @@ async def wrapper(*args, **kwargs):
else:
function_result = await run_in_threadpool(func, *args, **kwargs)
if "test_request" in kwargs and kwargs["test_request"]:
_LOGGER.info(
f"Test request was executed. For '{event}' event with: {args}, {kwargs}. No results saved."
_LOGGER.debug(
f"Test request executed for '{event}' event. No results saved."
)
return function_result
request = kwargs.get("request")
Expand Down
72 changes: 58 additions & 14 deletions bedhost/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

Expand All @@ -34,13 +35,14 @@
init_model_usage,
upload_usage,
)
from .uploads import upload_size_guard

# This module is the application entry point (uvicorn loads `bedhost.main:app`),
# so it is where logging gets configured. Don't move this into `__init__.py`.
logmuse.init_logger(PKG_NAME)

logging.getLogger("bbconf").setLevel(logging.DEBUG)
logging.getLogger("geniml").setLevel(logging.DEBUG)
LOG_LEVEL = os.environ.get("BEDHOST_LOG_LEVEL", "INFO").upper()
logmuse.init_logger(PKG_NAME, level=LOG_LEVEL)
for name in ("bbconf", "geniml"):
logging.getLogger(name).setLevel(LOG_LEVEL)

tags_metadata = [
{
Expand Down Expand Up @@ -109,21 +111,36 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
_LOGGER.info("Initializing reference genome validator...")
app.state.ref_validator = ReferenceValidator()

scheduler = BackgroundScheduler()
scheduler.add_job(
upload_usage,
"interval",
hours=USAGE_SAVE_HOURS,
args=(app.state.bbagent, app.state.usage_data),
)
scheduler.start()
app.state.scheduler = scheduler
# Read-only guard: when BEDHOST_DISABLE_USAGE_SCHEDULER is truthy, do NOT
# start the hourly usage-flush job (which writes to the DB). Default (unset)
# keeps the scheduler ON so normal/prod deployments are unaffected.
if os.environ.get("BEDHOST_DISABLE_USAGE_SCHEDULER", "").lower() not in (
"1",
"true",
"yes",
):
scheduler = BackgroundScheduler()
scheduler.add_job(
upload_usage,
"interval",
hours=USAGE_SAVE_HOURS,
args=(app.state.bbagent, app.state.usage_data),
)
scheduler.start()
app.state.scheduler = scheduler
else:
app.state.scheduler = None
_LOGGER.info(
"BEDHOST_DISABLE_USAGE_SCHEDULER set; usage flush scheduler NOT "
"started (read-only mode)."
)

try:
_LOGGER.info("Starting app ...")
yield
finally:
app.state.scheduler.shutdown(wait=False)
if app.state.scheduler is not None:
app.state.scheduler.shutdown(wait=False)


app = FastAPI(
Expand All @@ -144,6 +161,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"*", # allow cross-origin resource sharing, since this is a public API
]

# Registered before CORSMiddleware so CORS runs outside it and its 411/413
# responses still carry CORS headers for browser clients.
app.middleware("http")(upload_size_guard)

app.add_middleware(
CORSMiddleware,
allow_origins=origins,
Expand Down Expand Up @@ -224,6 +245,29 @@ async def exc_handler_MissingObjectError(req: Request, exc: MissingObjectError):
attach_routers(app)


def _answer_head_wherever_we_answer_get(routes) -> None:
"""
Starlette's plain `Route` adds HEAD alongside GET automatically; FastAPI's
`APIRoute` does not, so uptime monitors that send HEAD (UptimeRobot's basic
monitor, for one) get a 405. Adding the method makes Starlette run the GET
handler and drop the body.

FastAPI 0.141 stopped flattening included routers into `app.routes` and
keeps a wrapper object holding the original router instead, so recurse
through those as well. Must run before the first request: the wrapper
caches an effective view of its routes on first match.
"""
for route in routes:
if isinstance(route, APIRoute) and "GET" in route.methods:
route.methods.add("HEAD")
included = getattr(route, "original_router", None)
if included is not None:
_answer_head_wherever_we_answer_get(included.routes)


_answer_head_wherever_we_answer_get(app.routes)


def main() -> None:
parser = build_parser()
args = parser.parse_args()
Expand Down
Loading
Loading