From 001fdb5a733dd5d517f7a94475c1b091fa3f223d Mon Sep 17 00:00:00 2001 From: t0xa Date: Fri, 23 Oct 2026 20:53:18 +0300 Subject: [PATCH] DevOps: Add basic build script --- CLAUDE.md | 2 ++ backend/Dockerfile | 45 ++++++++++++----------- compose.yaml | 1 + scripts/release.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 20 deletions(-) create mode 100755 scripts/release.py diff --git a/CLAUDE.md b/CLAUDE.md index 6072b45..805c00c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,8 @@ docker compose down -v ``` Rebuild (`--build`) is needed only after changing `requirements*.txt`, `Dockerfile` or `pyproject.toml`. +The backend `Dockerfile` has two targets: `dev` (default in compose: pytest, mypy, tests, notes) and `prod` (runtime dependencies and app code only). Production images are built with `docker build --target prod backend`; the prod image has no `data/`, so the dev-only `GET /api/v1/trainings/obsidian/` doesn't work there. + ### Testing Tests that need a database use a separate `_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped. ```bash diff --git a/backend/Dockerfile b/backend/Dockerfile index be69ea8..784115c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,32 +1,37 @@ -FROM python:3.13-slim +# Two images from one file: +# docker build --target prod . - runtime only: no pytest/mypy, no tests, no personal notes +# docker build --target dev . - prod + dev tools, tests and notes (docker compose uses it) + +FROM python:3.13-slim AS base -# Set working directory WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - build-essential \ - && rm -rf /var/lib/apt/lists/* +ENV PYTHONPATH=/app # Copy requirements first for better caching -COPY requirements.txt requirements-dev.txt ./ +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -# Install Python dependencies (the project is run only in Docker, so dev tools -# like pytest and mypy are part of the image) -RUN pip install --no-cache-dir -r requirements-dev.txt - -# Copy application code -COPY pyproject.toml alembic.ini ./ +COPY alembic.ini ./ COPY ./app ./app COPY ./migrations ./migrations + +EXPOSE 8000 + + +FROM base AS prod + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + + +# The last stage is the default target. The project is developed only in Docker, so the dev +# tools (pytest, mypy) live in this image. +FROM base AS dev + +COPY requirements-dev.txt pyproject.toml ./ +RUN pip install --no-cache-dir -r requirements-dev.txt + COPY ./data ./data COPY ./tests ./tests -# Set Python path -ENV PYTHONPATH=/app - -# Expose port -EXPOSE 8000 - # Command will be overridden by docker-compose CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/compose.yaml b/compose.yaml index 99df67a..f979226 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,6 +3,7 @@ services: build: context: ./backend dockerfile: Dockerfile + target: dev ports: - "8000:8000" volumes: diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..3901485 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Builds the production images of the backend and the frontend with a version tag. + + python scripts/release.py 0.0.1 # an exact version + python scripts/release.py patch # bump: patch | minor | major (0.0.1 -> 0.0.2) + python scripts/release.py patch --platform linux/arm64 # for a server of another architecture + python scripts/release.py --dry-run patch # only print what would be done + +The version is kept in the VERSION file in the repository root (the bump starts from 0.0.0 +when there is no such file). Images: fitness_parser_backend: (target `prod`) and +fitness_parser_frontend:. +""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VERSION_FILE = ROOT / "VERSION" +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") + +# image name -> (build context, extra docker build arguments) +IMAGES = { + "fitness_parser_backend": ("backend", ["--target", "prod"]), + "fitness_parser_frontend": ("frontend", []), +} + + +def current_version() -> str: + if not VERSION_FILE.exists(): + return "0.0.0" + return VERSION_FILE.read_text().strip() + + +def next_version(request: str) -> str: + if VERSION_PATTERN.match(request): + return request + major, minor, patch = (int(part) for part in current_version().split(".")) + if request == "major": + return f"{major + 1}.0.0" + if request == "minor": + return f"{major}.{minor + 1}.0" + if request == "patch": + return f"{major}.{minor}.{patch + 1}" + sys.exit(f"Unknown version '{request}': use X.Y.Z, patch, minor or major") + + +def build_command(name: str, version: str, platform: str | None) -> list[str]: + context, extra = IMAGES[name] + if platform: + # buildx can build for another architecture; --load puts the image into local docker + command = ["docker", "buildx", "build", "--platform", platform, "--load"] + else: + command = ["docker", "build"] + return [*command, *extra, "-t", f"{name}:{version}", str(ROOT / context)] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("version", help="X.Y.Z, or patch / minor / major to bump the current one") + parser.add_argument("--platform", help="target platform, e.g. linux/arm64 (default: this machine)") + parser.add_argument("--dry-run", action="store_true", help="print the commands, change nothing") + args = parser.parse_args() + + version = next_version(args.version) + print(f"Version: {current_version()} -> {version}") + commands = [build_command(name, version, args.platform) for name in IMAGES] + + if args.dry_run: + for command in commands: + print(" ".join(command)) + return + + for command in commands: + print("+", " ".join(command), flush=True) + if subprocess.run(command).returncode != 0: + sys.exit("Build failed, the VERSION file was not changed") + # Written only after both images were built + VERSION_FILE.write_text(version + "\n") + + images = " ".join(f"{name}:{version}" for name in IMAGES) + print(f"\nBuilt {images}\nSend to the server:\n" + f" docker save {images} | gzip | ssh user@host 'gunzip | docker load'") + + +if __name__ == "__main__": + main()