DevOps: Add basic build script
This commit is contained in:
parent
582a80564e
commit
001fdb5a73
4 changed files with 117 additions and 20 deletions
|
|
@ -38,6 +38,8 @@ docker compose down -v
|
||||||
```
|
```
|
||||||
Rebuild (`--build`) is needed only after changing `requirements*.txt`, `Dockerfile` or `pyproject.toml`.
|
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
|
### Testing
|
||||||
Tests that need a database use a separate `<POSTGRES_DATABASE_DB>_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped.
|
Tests that need a database use a separate `<POSTGRES_DATABASE_DB>_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped.
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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
|
WORKDIR /app
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
build-essential \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# 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
|
COPY alembic.ini ./
|
||||||
# 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 ./app ./app
|
COPY ./app ./app
|
||||||
COPY ./migrations ./migrations
|
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 ./data ./data
|
||||||
COPY ./tests ./tests
|
COPY ./tests ./tests
|
||||||
|
|
||||||
# Set Python path
|
|
||||||
ENV PYTHONPATH=/app
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Command will be overridden by docker-compose
|
# Command will be overridden by docker-compose
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: dev
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
89
scripts/release.py
Executable file
89
scripts/release.py
Executable file
|
|
@ -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:<version> (target `prod`) and
|
||||||
|
fitness_parser_frontend:<version>.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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()
|
||||||
Loading…
Reference in a new issue