From ac0ccf47e52b5f61871ea3492a9c344ac8c0b57b Mon Sep 17 00:00:00 2001 From: h Date: Wed, 9 Sep 2026 00:18:15 +0200 Subject: [PATCH] Override-based compose, profiles in .env, README --- template/{{ part_dir }}/.env.example.jinja | 41 +++++++------------ template/{{ part_dir }}/Dockerfile.jinja | 2 + template/{{ part_dir }}/Makefile.jinja | 23 +++++------ .../docker-compose.local.yml.jinja | 15 ------- .../docker-compose.override.yml.example.jinja | 28 +++++++++++++ .../docker-compose.prod.yml.jinja | 15 ------- .../{{ part_dir }}/docker-compose.yml.jinja | 24 ++++++++++- .../{{ part_dir }}/src/utils/env.py.jinja | 6 ++- .../__main__.py.jinja | 6 ++- .../app.py.jinja | 10 +++-- .../runner.py.jinja | 18 +++++--- .../__init__.py.jinja | 13 +++--- ...out == 'root' %}README.md{% endif %}.jinja | 18 ++++++++ 13 files changed, 131 insertions(+), 88 deletions(-) delete mode 100644 template/{{ part_dir }}/docker-compose.local.yml.jinja create mode 100644 template/{{ part_dir }}/docker-compose.override.yml.example.jinja delete mode 100644 template/{{ part_dir }}/docker-compose.prod.yml.jinja create mode 100644 template/{{ part_dir }}/{% if layout == 'root' %}README.md{% endif %}.jinja diff --git a/template/{{ part_dir }}/.env.example.jinja b/template/{{ part_dir }}/.env.example.jinja index f50d150..abf6bad 100644 --- a/template/{{ part_dir }}/.env.example.jinja +++ b/template/{{ part_dir }}/.env.example.jinja @@ -1,27 +1,14 @@ -{% if layout == 'root' %}COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml -{% endif %}RUN_ENVIRONMENT=dev -LOG__LEVEL=INFO -LOG__LEVEL_EXTERNAL=WARNING -LOG__SHOW_TIME=false -LOG__CONSOLE_WIDTH=150 -{% if database == 'mongo' %}DB__HOST=mongodb -DB__PORT=27017 -DB__USER={{ project_slug }} -DB__PASSWORD={{ project_slug }} -DB__DB_NAME={{ project_slug }} -{% endif %}{% if database == 'postgres' %}DB__HOST=postgres -DB__PORT=5432 -DB__USER={{ project_slug }} -DB__PASSWORD={{ project_slug }} -DB__DB_NAME={{ project_slug }} -{% endif %}{% if use_redis %}REDIS__HOST=redis -REDIS__PORT=6379 -REDIS__CACHE_TTL=300 -{% endif %}{% if 'api' in backend_services %}API__HOST=0.0.0.0 -API__PORT=8080 -API__WORKERS=1 -{% endif %}{% if 'aiogram_bot' in backend_services %}BOT__TOKEN= -{% endif %}{% if include_payments %}CRYPTO_PAY__TOKEN= -{% endif %}{% if use_llm %}LLM__MODEL=google-gla:gemini-2.5-flash -LLM__GEMINI_API_KEY= -{% endif %} +{%- set groups = [] -%} +{%- if layout == 'root' %}{% set _ = groups.append(["COMPOSE_PROFILES=" ~ ("external," if database != 'none' or use_redis else "") ~ "services"]) %}{% endif -%} +{%- set _ = groups.append(["RUN_ENVIRONMENT=prod", "TZ=UTC"]) -%} +{%- set _ = groups.append(["LOG__LEVEL=INFO", "LOG__LEVEL_EXTERNAL=WARNING", "LOG__SHOW_TIME=false", "LOG__CONSOLE_WIDTH=150"]) -%} +{%- if database == 'mongo' %}{% set _ = groups.append(["DB__HOST=mongodb", "DB__PORT=27017", "DB__USER=" ~ project_slug, "DB__PASSWORD=" ~ project_slug, "DB__DB_NAME=" ~ project_slug]) %}{% endif -%} +{%- if database == 'postgres' %}{% set _ = groups.append(["DB__HOST=postgres", "DB__PORT=5432", "DB__USER=" ~ project_slug, "DB__PASSWORD=" ~ project_slug, "DB__DB_NAME=" ~ project_slug]) %}{% endif -%} +{%- if use_redis %}{% set _ = groups.append(["REDIS__HOST=redis", "REDIS__PORT=6379", "REDIS__CACHE_TTL=300"]) %}{% endif -%} +{%- if 'api' in backend_services %}{% set _ = groups.append(["API__HOST=0.0.0.0", "API__PORT=8080", "API__WORKERS=1", "API__DOCS=false"]) %}{% endif -%} +{%- if 'aiogram_bot' in backend_services %}{% set _ = groups.append(["BOT__TOKEN="]) %}{% endif -%} +{%- if include_payments %}{% set _ = groups.append(["CRYPTO_PAY__TOKEN="]) %}{% endif -%} +{%- if use_llm %}{% set _ = groups.append(["LLM__MODEL=google-gla:gemini-2.5-flash", "LLM__GEMINI_API_KEY="]) %}{% endif -%} +{% for group in groups %}{{ group | join("\n") }} +{% if not loop.last %} +{% endif %}{% endfor %} \ No newline at end of file diff --git a/template/{{ part_dir }}/Dockerfile.jinja b/template/{{ part_dir }}/Dockerfile.jinja index 112ca3d..93fed17 100644 --- a/template/{{ part_dir }}/Dockerfile.jinja +++ b/template/{{ part_dir }}/Dockerfile.jinja @@ -10,6 +10,8 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* {%- else -%} FROM ghcr.io/astral-sh/uv:python3.13-alpine + +RUN apk add --no-cache tzdata {%- endif %} WORKDIR /app diff --git a/template/{{ part_dir }}/Makefile.jinja b/template/{{ part_dir }}/Makefile.jinja index 2f349bc..7755835 100644 --- a/template/{{ part_dir }}/Makefile.jinja +++ b/template/{{ part_dir }}/Makefile.jinja @@ -6,10 +6,8 @@ {%- if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}{% set _ = svc_modules.append("scheduler") %}{% endif -%} {%- set has_script = dynamic_config and svc_modules -%} COMPOSE := docker compose{% if layout == 'part' %} --project-directory ..{% endif %} -{% if layout == 'root' %}SERVICES := $(COMPOSE) --profile services -EXTERNAL := $(COMPOSE) --profile external -{% endif %} -.PHONY: {% if layout == 'root' %}env external recreate rebuild restart logs down {% endif %}fmt check build{% if database == 'postgres' %} migrate revision{% endif %}{% if has_script %} script{% endif %}{% if 'kurigram_userbot' in backend_services %} session{% endif %} + +.PHONY: {% if layout == 'root' %}env recreate rebuild restart logs down {% endif %}fmt check build{% if database == 'postgres' %} migrate revision{% endif %}{% if has_script %} script{% endif %}{% if 'kurigram_userbot' in backend_services %} session{% endif %} fmt: uv run ruff format @@ -25,23 +23,22 @@ build: {% if layout == 'root' %} env: @test -f .env || cp .env.example .env + @test -f docker-compose.override.yml || cp docker-compose.override.yml.example docker-compose.override.yml -external: - $(EXTERNAL) up -d +recreate: + $(COMPOSE) up -d --force-recreate -recreate: external - $(SERVICES) up -d --force-recreate - -rebuild: build recreate +rebuild: build + $(COMPOSE) up -d restart: - $(SERVICES) restart + $(COMPOSE) restart logs: - $(SERVICES) logs -f --tail=100 + $(COMPOSE) logs -f --tail=100 down: - $(EXTERNAL) --profile services down + $(COMPOSE) down {% endif %}{% if database == 'postgres' %} migrate: $(COMPOSE) --profile external run --rm migrator diff --git a/template/{{ part_dir }}/docker-compose.local.yml.jinja b/template/{{ part_dir }}/docker-compose.local.yml.jinja deleted file mode 100644 index 546a2c9..0000000 --- a/template/{{ part_dir }}/docker-compose.local.yml.jinja +++ /dev/null @@ -1,15 +0,0 @@ -{%- set has_ports = 'api' in backend_services or database != 'none' or use_redis -%} -services:{% if not has_ports %} {}{% endif %} -{% if 'api' in backend_services %} api: - ports: - - "127.0.0.1:${API__PORT:-8080}:8080" -{% endif %}{% if database == 'mongo' %} mongodb: - ports: - - "127.0.0.1:${DB__PORT:-27017}:${DB__PORT:-27017}" -{% endif %}{% if database == 'postgres' %} postgres: - ports: - - "127.0.0.1:${DB__PORT:-5432}:5432" -{% endif %}{% if use_redis %} redis: - ports: - - "127.0.0.1:${REDIS__PORT:-6379}:6379" -{% endif %} diff --git a/template/{{ part_dir }}/docker-compose.override.yml.example.jinja b/template/{{ part_dir }}/docker-compose.override.yml.example.jinja new file mode 100644 index 0000000..96e3ead --- /dev/null +++ b/template/{{ part_dir }}/docker-compose.override.yml.example.jinja @@ -0,0 +1,28 @@ +{%- set dash = project_slug | replace('_', '-') -%} +{%- set entries = [] -%} +{%- if 'api' in backend_services %}{% set _ = entries.append('api') %}{% endif -%} +{%- if database == 'mongo' %}{% set _ = entries.append('mongodb') %}{% endif -%} +{%- if database == 'postgres' %}{% set _ = entries.append('postgres') %}{% endif -%} +{%- if use_redis %}{% set _ = entries.append('redis') %}{% endif -%} +services:{% if not entries %} {}{% endif %} +{% for entry in entries %}{% if entry == 'api' %} api: + networks: + default: {} + caddy: + aliases: + - {{ dash }}-api +{% elif entry == 'mongodb' %} mongodb: + ports: + - "127.0.0.1:${DB__PORT:-27017}:${DB__PORT:-27017}" +{% elif entry == 'postgres' %} postgres: + ports: + - "127.0.0.1:${DB__PORT:-5432}:5432" +{% elif entry == 'redis' %} redis: + ports: + - "127.0.0.1:${REDIS__PORT:-6379}:6379" +{% endif %}{% if not loop.last %} +{% endif %}{% endfor %}{% if 'api' in backend_services %} +networks: + caddy: + external: true +{% endif %} diff --git a/template/{{ part_dir }}/docker-compose.prod.yml.jinja b/template/{{ part_dir }}/docker-compose.prod.yml.jinja deleted file mode 100644 index 458e46d..0000000 --- a/template/{{ part_dir }}/docker-compose.prod.yml.jinja +++ /dev/null @@ -1,15 +0,0 @@ -{%- set dash = project_slug | replace('_', '-') -%} -{% if 'api' in backend_services -%} -services: - api: - networks: - caddy: - aliases: - - {{ dash }}-api - -networks: - caddy: - external: true -{%- else -%} -services: {} -{%- endif %} diff --git a/template/{{ part_dir }}/docker-compose.yml.jinja b/template/{{ part_dir }}/docker-compose.yml.jinja index 640a2b4..5ff06c2 100644 --- a/template/{{ part_dir }}/docker-compose.yml.jinja +++ b/template/{{ part_dir }}/docker-compose.yml.jinja @@ -2,6 +2,10 @@ {%- set svc_networks = [] -%} {%- if database != 'none' %}{% set _ = svc_networks.append('database') %}{% endif -%} {%- if use_redis %}{% set _ = svc_networks.append('redis') %}{% endif -%} +{%- set external_services = [] -%} +{%- if database == 'mongo' %}{% set _ = external_services.append('mongodb') %}{% endif -%} +{%- if database == 'postgres' %}{% set _ = external_services.append('postgres') %}{% endif -%} +{%- if use_redis %}{% set _ = external_services.append('redis') %}{% endif -%} {%- set app_services = [] -%} {%- if 'api' in backend_services %}{% set _ = app_services.append({'name': 'api', 'command': '[api]'}) %}{% endif -%} {%- if 'aiogram_bot' in backend_services %}{% set _ = app_services.append({'name': 'bot', 'command': '[bot]'}) %}{% endif -%} @@ -20,14 +24,20 @@ required: false {% endif %} environment: RUN_ENVIRONMENT: prod + TZ: ${TZ:-UTC} volumes: - ./{{ prefix }}src:/app/src {% if has_scripts %} - ./{{ prefix }}scripts:/app/scripts {% endif %}{% if name == 'userbot' %} - ./{{ prefix }}sessions:/app/sessions {% endif %}{% if name == 'api' and serve_spa %} - ./frontend/build:/app/static:ro {% endif %} command: {{ command }} -{% if svc_networks %} networks: -{% for net in svc_networks %} {{ net }}: +{% if external_services %} depends_on: +{% for ext in external_services %} {{ ext }}: + condition: service_healthy + required: false +{% endfor %}{% endif %}{% if svc_networks %} networks: +{% if name == 'api' %} default: +{% endif %}{% for net in svc_networks %} {{ net }}: {% endfor %}{% endif %}{% endmacro -%} services: {% for svc in app_services %}{{ backend_service(svc.name, svc.command) }}{% if not loop.last %} @@ -43,6 +53,11 @@ services: command: mongod --port ${DB__PORT:-27017} volumes: - database:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--port", "${DB__PORT:-27017}", "--eval", "db.adminCommand('ping')"] + interval: 5s + timeout: 5s + retries: 5 networks: database: aliases: @@ -98,6 +113,11 @@ services: profiles: [redis, external] restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 volumes: - redis_data:/data networks: diff --git a/template/{{ part_dir }}/src/utils/env.py.jinja b/template/{{ part_dir }}/src/utils/env.py.jinja index 6dc2095..7235165 100644 --- a/template/{{ part_dir }}/src/utils/env.py.jinja +++ b/template/{{ part_dir }}/src/utils/env.py.jinja @@ -69,6 +69,7 @@ class ApiSettings(BaseSettings): host: str = "0.0.0.0" # noqa: S104 port: int = 8080 workers: int = 1 + docs: bool = False {%- if serve_spa %} static_dir: str = "static" {%- endif %} @@ -110,7 +111,10 @@ class Settings(BaseSettings): {%- endif %} model_config = SettingsConfigDict( - case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore" + case_sensitive=False, + env_file={% if layout == 'part' %}("../.env", ".env"){% else %}".env"{% endif %}, + env_nested_delimiter="__", + extra="ignore", ) diff --git a/template/{{ part_dir }}/src/{% if 'api' in backend_services %}api{% endif %}/__main__.py.jinja b/template/{{ part_dir }}/src/{% if 'api' in backend_services %}api{% endif %}/__main__.py.jinja index ff9384e..8eef77f 100644 --- a/template/{{ part_dir }}/src/{% if 'api' in backend_services %}api{% endif %}/__main__.py.jinja +++ b/template/{{ part_dir }}/src/{% if 'api' in backend_services %}api{% endif %}/__main__.py.jinja @@ -5,7 +5,11 @@ from utils.env import env def main() -> None: uvicorn.run( - "api.app:app", host=env.api.host, port=env.api.port, workers=env.api.workers + "api.app:app", + host=env.api.host, + port=env.api.port, + workers=env.api.workers, + forwarded_allow_ips="*", ) diff --git a/template/{{ part_dir }}/src/{% if 'api' in backend_services %}api{% endif %}/app.py.jinja b/template/{{ part_dir }}/src/{% if 'api' in backend_services %}api{% endif %}/app.py.jinja index 4dfb68b..e8c0734 100644 --- a/template/{{ part_dir }}/src/{% if 'api' in backend_services %}api{% endif %}/app.py.jinja +++ b/template/{{ part_dir }}/src/{% if 'api' in backend_services %}api{% endif %}/app.py.jinja @@ -10,8 +10,8 @@ from fastapi.middleware.cors import CORSMiddleware from api import routers {% if use_dishka %}from dependencies.container import container {% endif %}{% if database != 'none' %}from utils.db import init_db -{% endif %}{% if serve_spa %}from utils.env import env -{% endif %}from utils.logging import setup_logging +{% endif %}from utils.env import env +from utils.logging import setup_logging {% if serve_spa %} IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} NO_CACHE_HEADERS = {"Cache-Control": "no-cache"} @@ -29,7 +29,11 @@ async def lifespan(app_: FastAPI) -> AsyncGenerator[None]: {%- endif %} -app = FastAPI(title="{{ project_name }} API", lifespan=lifespan) +app = FastAPI( + title="{{ project_name }} API", + lifespan=lifespan, + openapi_url="/openapi.json" if env.api.docs else None, +) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] diff --git a/template/{{ part_dir }}/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/runner.py.jinja b/template/{{ part_dir }}/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/runner.py.jinja index 9e84452..feefa07 100644 --- a/template/{{ part_dir }}/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/runner.py.jinja +++ b/template/{{ part_dir }}/src/{% if 'kurigram_userbot' in backend_services %}userbot{% endif %}/runner.py.jinja @@ -1,5 +1,6 @@ import asyncio import contextlib +import signal import anyio import uvloop @@ -11,8 +12,15 @@ import uvloop setup_logging() +SCAN_INTERVAL = 10 + async def runner() -> None: + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop.set) + {% if database != 'none' %} await init_db() {% endif %} sessions_dir = anyio.Path("sessions") @@ -22,7 +30,7 @@ async def runner() -> None: clients: dict[int, PyroClient] = {} try: - while True: + while not stop.is_set(): current = {path.name async for path in sessions_dir.glob("*.session")} for session_file in current - started: @@ -43,7 +51,8 @@ async def runner() -> None: if me: clients[me.id] = client - await asyncio.sleep(10) + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(stop.wait(), SCAN_INTERVAL) finally: for client in clients.values(): with contextlib.suppress(Exception): @@ -55,8 +64,5 @@ def main() -> None: uvloop.install() logger.info("Starting...") - - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(runner()) - + asyncio.run(runner()) logger.info("[red]Stopped.[/]") diff --git a/template/{{ part_dir }}/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__init__.py.jinja b/template/{{ part_dir }}/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__init__.py.jinja index 0dcf9c2..5592368 100644 --- a/template/{{ part_dir }}/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__init__.py.jinja +++ b/template/{{ part_dir }}/src/{% if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}scheduler{% endif %}/__init__.py.jinja @@ -1,5 +1,5 @@ import asyncio -import contextlib +import signal from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger @@ -15,6 +15,10 @@ async def example_job() -> None: async def runner() -> None: + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop.set) {%- if database != 'none' %} await init_db() {%- endif %} @@ -27,14 +31,13 @@ async def runner() -> None: scheduler.start() logger.info("Scheduler started") - with contextlib.suppress(asyncio.CancelledError): - await asyncio.Event().wait() + await stop.wait() + scheduler.shutdown() def main() -> None: setup_logging() logger.info("Starting...") - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(runner()) + asyncio.run(runner()) logger.info("[red]Stopped.[/]") diff --git a/template/{{ part_dir }}/{% if layout == 'root' %}README.md{% endif %}.jinja b/template/{{ part_dir }}/{% if layout == 'root' %}README.md{% endif %}.jinja new file mode 100644 index 0000000..f46fb75 --- /dev/null +++ b/template/{{ part_dir }}/{% if layout == 'root' %}README.md{% endif %}.jinja @@ -0,0 +1,18 @@ +{%- set targets = [] -%} +{%- if database == 'postgres' %}{% set _ = targets.append("`make migrate`, `make revision m=\"message\"`") %}{% endif -%} +{%- if dynamic_config and backend_services %}{% set _ = targets.append("`make script name`") %}{% endif -%} +{%- if 'kurigram_userbot' in backend_services %}{% set _ = targets.append("`make session`") %}{% endif -%} +# {{ project_name }} +{% if project_description %} +{{ project_description }} +{% endif %} +Runs in Docker, locally the same way as in production. `COMPOSE_PROFILES` in `.env` selects the services that run on this machine. `docker-compose.override.yml` holds what differs between machines: published ports and the aliases on the external `caddy` network. + +```sh +make env +make recreate +``` + +`make env` creates `.env` and `docker-compose.override.yml` from their examples. `make recreate` starts the selected profiles, `make rebuild` builds the image first, `make logs`, `make restart` and `make down` do what they say. + +`make fmt` formats, `make check` runs ruff and ty.{% if targets %} Also {{ targets | join(", ") }}.{% endif %}