"""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()