Files
dotfiles/python/ebook_search/docker/containers.py
T
Richie 6bf77299e8
treefmt / nix fmt (pull_request) Failing after 5s
pytest / pytest (pull_request) Successful in 28s
test ebook search / test-ebook-search (pull_request) Failing after 35s
build_systems / build-brain (pull_request) Successful in 49s
build_systems / build-bob (pull_request) Successful in 49s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m0s
build_systems / build-jeeves (pull_request) Successful in 2m20s
Refactor logging statements to use f-strings for improved readability and consistency across the codebase. This change enhances the clarity of log messages by directly embedding variable values, making it easier to trace and debug application behavior.
2026-07-12 19:34:19 -04:00

260 lines
7.7 KiB
Python

"""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(f"docker {' '.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 build_test_image() -> None:
"""Build the ebook search test Docker image."""
dockerfile = Path(__file__).resolve().with_name("Dockerfile")
result = docker_run(["build", "--file", str(dockerfile), "--target", "test", "--tag", "ebook-search:test", "."])
if result.returncode != 0:
msg = "Failed to build ebook search test image"
raise RuntimeError(msg)
def run_test_image() -> None:
"""Run the ebook search test suite inside Docker."""
result = docker_run(["run", "--rm", "ebook-search:test"])
if result.returncode != 0:
msg = f"Ebook search tests failed with code {result.returncode}"
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("test")
def run_tests(
*,
build: Annotated[bool, typer.Option("--build/--no-build", help="Build the test image before running.")] = True,
log_level: Annotated[str, typer.Option(help="Log level.")] = "INFO",
) -> None:
"""Run ebook search tests inside the Docker test image."""
configure_logger(log_level)
if build:
build_test_image()
run_test_image()
@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()