From f5ec88f1e5d01ff4521aee775f83890dd8655679 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Thu, 2 Jul 2026 16:42:10 -0400 Subject: [PATCH] feat(ebook): add Docker packaging and lifecycle tooling Add a self-contained docker/ package for running the ebook search app against the existing Postgres database on jeeves: - Dockerfile: python:3.14-slim image, non-root user, runs the FastAPI app on port 8070 - docker-compose.yml: service definition with library volume mount, BM25 index volume, .env loading, and a /health healthcheck - containers.py: Typer CLI (ebook-search-containers) for build/start/ stop/restart/logs/ps lifecycle management - README.md: usage and configuration docs --- python/ebook_search/docker/Dockerfile | 49 ++++ python/ebook_search/docker/README.md | 40 +++ python/ebook_search/docker/__init__.py | 1 + python/ebook_search/docker/containers.py | 229 ++++++++++++++++++ python/ebook_search/docker/docker-compose.yml | 36 +++ 5 files changed, 355 insertions(+) create mode 100644 python/ebook_search/docker/Dockerfile create mode 100644 python/ebook_search/docker/README.md create mode 100644 python/ebook_search/docker/__init__.py create mode 100644 python/ebook_search/docker/containers.py create mode 100644 python/ebook_search/docker/docker-compose.yml diff --git a/python/ebook_search/docker/Dockerfile b/python/ebook_search/docker/Dockerfile new file mode 100644 index 0000000..6bbfcbd --- /dev/null +++ b/python/ebook_search/docker/Dockerfile @@ -0,0 +1,49 @@ +FROM python:3.14-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + APP_DIR=/home/richie/dotfiles \ + EBOOK_SEARCH_HOST=0.0.0.0 \ + EBOOK_SEARCH_PORT=8070 \ + EBOOK_SEARCH_BM25_INDEX_DIR=/data/bm25 + +WORKDIR ${APP_DIR} + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml README.md LICENSE ./ +COPY python ./python + +RUN python -m pip install --upgrade pip \ + && python -m pip install \ + "alembic" \ + "beautifulsoup4" \ + "bm25s" \ + "ebooklib" \ + "fastapi" \ + "httpx" \ + "jinja2" \ + "pgvector" \ + "psycopg[binary]" \ + "pydantic" \ + "pydantic-settings" \ + "python-multipart" \ + "sqlalchemy" \ + "tiktoken" \ + "typer" \ + "uvicorn[standard]" \ + "yake" \ + && python -m pip install --no-deps --editable "${APP_DIR}" + +RUN useradd --create-home --uid 10001 app \ + && mkdir -p /data \ + && chown -R app:app /home/richie /data + +USER app + +EXPOSE 8070 + +CMD ["sh", "-c", "exec python -m python.ebook_search.api.main --host \"${EBOOK_SEARCH_HOST}\" --port \"${EBOOK_SEARCH_PORT}\" --log-level \"${EBOOK_SEARCH_LOG_LEVEL:-INFO}\""] diff --git a/python/ebook_search/docker/README.md b/python/ebook_search/docker/README.md new file mode 100644 index 0000000..64371a4 --- /dev/null +++ b/python/ebook_search/docker/README.md @@ -0,0 +1,40 @@ +# Ebook Search Docker + +Run the EPUB search app against the existing Postgres database on `jeeves`: + +```sh +ebook-search-containers start --library-path /path/to/epubs --build +``` + +All ebook-search Docker files live in this directory: + +- `Dockerfile` +- `docker-compose.yml` +- `containers.py` +- `container.py` + +The app listens on `http://localhost:8070`. + +Useful lifecycle commands: + +```sh +ebook-search-containers build +ebook-search-containers start --library-path /path/to/epubs +ebook-search-containers logs +ebook-search-containers ps +ebook-search-containers stop +``` + +Direct compose usage from the repo root: + +```sh +docker compose -f python/ebook_search/docker/docker-compose.yml ps +``` + +The compose service also loads the repo root `.env` into the container via `env_file`. + +Mount your EPUB directory by setting `EBOOK_LIBRARY_HOST_PATH` in an env file or on the command line. The container sees it as `/library`, and `EBOOK_SEARCH_LIBRARY_PATHS` is set to `/library` inside the container. + +Database connection settings are controlled by `RICHIE_DB`, `RICHIE_HOST`, `RICHIE_PORT`, `RICHIE_USER`, and `RICHIE_PASSWORD`. The default host is `jeeves`. + +Startup runs the Richie Alembic migrations automatically after creating the `main` schema and `vector` extension. diff --git a/python/ebook_search/docker/__init__.py b/python/ebook_search/docker/__init__.py new file mode 100644 index 0000000..b075d45 --- /dev/null +++ b/python/ebook_search/docker/__init__.py @@ -0,0 +1 @@ +"""Docker packaging and lifecycle tooling for ebook search.""" diff --git a/python/ebook_search/docker/containers.py b/python/ebook_search/docker/containers.py new file mode 100644 index 0000000..2828496 --- /dev/null +++ b/python/ebook_search/docker/containers.py @@ -0,0 +1,229 @@ +"""Docker container lifecycle management for ebook search.""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path +from typing import Annotated + +import typer + +from python.common import configure_logger, get_repo_dir + +logger = logging.getLogger(__name__) + + +def get_compose_file() -> Path: + """Return the path to the docker-compose.yml file.""" + return Path(__file__).resolve().with_name("docker-compose.yml") + + +def compose_base_args() -> list[str]: + """Return the common docker compose arguments for the ebook search stack.""" + return ["compose", "-f", str(get_compose_file())] + + +def docker_run( + arguments: list[str], + *, + env: dict[str, str] | None = None, + capture_output: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run docker with repo-root cwd and consistent error handling.""" + logger.info("docker %s", " ".join(arguments)) + return subprocess.run( + ["docker", *arguments], + cwd=get_repo_dir(), + env=env, + text=True, + check=False, + capture_output=capture_output, + ) + + +def compose_env(*, library_path: Path | None = None, port: int | None = None) -> dict[str, str]: + """Return environment variables passed to docker compose.""" + env = os.environ.copy() + if library_path is not None: + resolved_library = library_path.expanduser().resolve() + if not resolved_library.exists(): + msg = f"EPUB library path does not exist: {resolved_library}" + raise FileNotFoundError(msg) + env["EBOOK_LIBRARY_HOST_PATH"] = str(resolved_library) + if port is not None: + env["EBOOK_SEARCH_PORT"] = str(port) + return env + + +def ensure_compose_file() -> None: + """Raise if the ebook search compose file is missing.""" + if not get_compose_file().is_file(): + msg = f"Compose file not found: {get_compose_file()}" + raise FileNotFoundError(msg) + + +def build_image() -> None: + """Build the ebook search app image.""" + ensure_compose_file() + result = docker_run([*compose_base_args(), "build"]) + if result.returncode != 0: + msg = "Failed to build ebook search image" + raise RuntimeError(msg) + + +def start_stack( + *, + library_path: Path | None = None, + port: int | None = None, + build: bool = False, +) -> None: + """Start the ebook search Docker compose stack.""" + ensure_compose_file() + env = compose_env(library_path=library_path, port=port) + if build: + build_image() + result = docker_run( + [*compose_base_args(), "up", "-d"], + env=env, + ) + if result.returncode != 0: + msg = f"Ebook search stack failed to start with code {result.returncode}" + raise RuntimeError(msg) + logger.info("Ebook search started.") + + +def stop_stack( + *, + volumes: bool = False, +) -> None: + """Stop and remove ebook search containers.""" + ensure_compose_file() + command = [*compose_base_args(), "down"] + if volumes: + command.append("-v") + result = docker_run(command) + if result.returncode != 0: + msg = f"Ebook search stack failed to stop with code {result.returncode}" + raise RuntimeError(msg) + + +def logs_stack( + *, + service: str | None = None, + tail: int = 100, + follow: bool = False, +) -> str | None: + """Return recent logs from the ebook search stack.""" + ensure_compose_file() + command = [*compose_base_args(), "logs", "--tail", str(tail)] + if follow: + command.append("--follow") + if service: + command.append(service) + result = docker_run(command, capture_output=not follow) + if result.returncode != 0: + return None + if follow: + return "" + return result.stdout + result.stderr + + +def ps_stack() -> str | None: + """Return docker compose ps output for the ebook search stack.""" + ensure_compose_file() + result = docker_run([*compose_base_args(), "ps"], capture_output=True) + if result.returncode != 0: + return None + return result.stdout + result.stderr + + +app = typer.Typer(help="Ebook search Docker container management.", no_args_is_help=True) + + +@app.command() +def build() -> None: + """Build the ebook search Docker image.""" + build_image() + + +@app.command() +def start( + library_path: Annotated[Path | None, typer.Option(help="Override host path containing EPUB files.")] = None, + port: Annotated[int | None, typer.Option(help="Override host port for the web UI.")] = None, + *, + build: Annotated[bool, typer.Option("--build", help="Build the image before starting.")] = False, + log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO", +) -> None: + """Start the ebook search container.""" + configure_logger(log_level) + start_stack( + library_path=library_path, + port=port, + build=build, + ) + + +@app.command() +def stop( + *, + volumes: Annotated[bool, typer.Option("--volumes", help="Also remove ebook search data volumes.")] = False, + log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO", +) -> None: + """Stop and remove ebook search containers.""" + configure_logger(log_level) + stop_stack(volumes=volumes) + + +@app.command() +def restart( + library_path: Annotated[Path | None, typer.Option(help="Override host path containing EPUB files.")] = None, + port: Annotated[int | None, typer.Option(help="Override host port for the web UI.")] = None, + *, + build: Annotated[bool, typer.Option("--build", help="Build the image before starting.")] = False, + log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO", +) -> None: + """Restart the ebook search stack.""" + configure_logger(log_level) + stop_stack() + start_stack( + library_path=library_path, + port=port, + build=build, + ) + + +@app.command() +def logs( + service: Annotated[str | None, typer.Option(help="Service name, or omit for all services.")] = None, + tail: Annotated[int, typer.Option(help="Number of recent log lines.")] = 100, + *, + follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs.")] = False, +) -> None: + """Show recent ebook search container logs.""" + output = logs_stack(service=service, tail=tail, follow=follow) + if output is None: + typer.echo("No ebook search containers found.") + raise typer.Exit(code=1) + if output: + typer.echo(output) + + +@app.command("ps") +def ps() -> None: + """Show ebook search container status.""" + output = ps_stack() + if output is None: + typer.echo("No ebook search containers found.") + raise typer.Exit(code=1) + typer.echo(output) + + +def cli() -> None: + """Typer entry point.""" + app() + + +if __name__ == "__main__": + cli() diff --git a/python/ebook_search/docker/docker-compose.yml b/python/ebook_search/docker/docker-compose.yml new file mode 100644 index 0000000..37e1f42 --- /dev/null +++ b/python/ebook_search/docker/docker-compose.yml @@ -0,0 +1,36 @@ +name: ebook-search + +services: + ebook-search: + build: + context: ../../.. + dockerfile: python/ebook_search/docker/Dockerfile + image: ebook-search:latest + restart: unless-stopped + ports: + - "${EBOOK_SEARCH_PORT:-8070}:8070" + extra_hosts: + - "jeeves:192.168.90.40" + env_file: + - ../../../.env + environment: + EBOOK_SEARCH_HOST: "0.0.0.0" + EBOOK_SEARCH_PORT: "8070" + EBOOK_SEARCH_LIBRARY_PATHS: "/library" + EBOOK_SEARCH_BM25_INDEX_DIR: "/data/bm25" + volumes: + - "${EBOOK_LIBRARY_HOST_PATH:-/home/richie/ebooks}:/library:ro" + - ebook-search-data:/data + healthcheck: + test: + [ + "CMD-SHELL", + "curl -fsS http://127.0.0.1:8070/health >/dev/null || exit 1", + ] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + +volumes: + ebook-search-data: