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
This commit is contained in:
@@ -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}\""]
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
"""Docker packaging and lifecycle tooling for ebook search."""
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user