From d2270a737b2447a0c3386dd46d680da24aab8398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Tue, 14 Jul 2026 21:38:27 +0200 Subject: [PATCH] chore: makefile + setup --- .env.example | 17 +++++ .gitignore | 1 + Makefile | 132 +++++++++++++++++++++++++++++++++ README.md | 39 +++++++--- deploy/osiris.service.template | 28 +++++++ deploy/send-reminders.sh | 16 ++++ pyproject.toml | 1 + uv.lock | 23 ++++++ 8 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 .env.example create mode 100644 Makefile create mode 100644 deploy/osiris.service.template create mode 100755 deploy/send-reminders.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5d3c8ad --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Copy to .env (which is gitignored) — `make env` generates one with fresh secrets. +# Both the systemd service and the reminder cron job read this file. +# +# Values must stay shell-safe and unquoted: the cron wrapper sources this file, so +# a secret containing ')' or '$' would break it. `make env` uses a URL-safe alphabet. + +DJANGO_SECRET_KEY=change-me +DJANGO_DEBUG=0 +DJANGO_ALLOWED_HOSTS=osiris.legrems.xyz,127.0.0.1,localhost +DJANGO_CSRF_TRUSTED_ORIGINS=https://osiris.legrems.xyz +DJANGO_TIME_ZONE=Europe/Zurich + +# From `make vapid` / `manage.py generate_vapid_keys`. Without these, reminders +# are disabled and the Today page's Reminders card says so. +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_CLAIM_EMAIL=flamelegrems@gmail.com diff --git a/.gitignore b/.gitignore index 349cf7b..832cda5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ wheels/ db.sqlite3 static/ .env +reminders.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4608651 --- /dev/null +++ b/Makefile @@ -0,0 +1,132 @@ +.DEFAULT_GOAL := help + +UV := $(shell command -v uv 2>/dev/null) +PROJECT_DIR := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +USER := $(shell id -un) +PORT ?= 8000 +SERVICE := osiris +UNIT_FILE := /etc/systemd/system/$(SERVICE).service +REMINDER_SH := $(PROJECT_DIR)/deploy/send-reminders.sh +CRON_SCHED ?= */10 * * * * +CRON_LOG ?= $(PROJECT_DIR)/reminders.log +MANAGE := $(UV) run python manage.py + +.PHONY: help +help: ## Show this help + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +# --- Setup ----------------------------------------------------------------- + +.PHONY: install +install: check-uv ## Install dependencies, create .env, migrate, collect static files + $(UV) sync + @$(MAKE) --no-print-directory env # after sync: it needs Django importable + $(MANAGE) migrate + $(MANAGE) collectstatic --noinput + @echo "Installed. Next: 'make superuser' (once), then 'make service' and 'make cron'." + +.PHONY: check-uv +check-uv: + @test -n "$(UV)" || { echo "uv is not installed: https://docs.astral.sh/uv/"; exit 1; } + +.PHONY: env +env: ## Create .env with a fresh SECRET_KEY and VAPID keys (keeps an existing one) + @if [ -f .env ]; then \ + echo ".env already exists — leaving it alone."; \ + else \ + cp .env.example .env; \ + secret=$$($(UV) run python -c "import secrets; print(secrets.token_urlsafe(64))"); \ + sed -i "s|^DJANGO_SECRET_KEY=.*|DJANGO_SECRET_KEY=$$secret|" .env; \ + $(MAKE) --no-print-directory vapid; \ + echo "Wrote .env — review DJANGO_ALLOWED_HOSTS before going live."; \ + fi + +.PHONY: vapid +vapid: ## Generate VAPID keys and write them into .env + @test -f .env || cp .env.example .env + @keys=$$($(MANAGE) generate_vapid_keys 2>/dev/null | grep '^VAPID_'); \ + pub=$$(echo "$$keys" | grep '^VAPID_PUBLIC_KEY=' | cut -d= -f2); \ + priv=$$(echo "$$keys" | grep '^VAPID_PRIVATE_KEY=' | cut -d= -f2); \ + test -n "$$pub" || { echo "Key generation failed."; exit 1; }; \ + sed -i "s|^VAPID_PUBLIC_KEY=.*|VAPID_PUBLIC_KEY=$$pub|" .env; \ + sed -i "s|^VAPID_PRIVATE_KEY=.*|VAPID_PRIVATE_KEY=$$priv|" .env; \ + echo "VAPID keys written to .env. Restart the service, then re-enable reminders on each device." + +.PHONY: superuser +superuser: ## Create the login account (there is no registration) + $(MANAGE) createsuperuser + +# --- systemd --------------------------------------------------------------- + +.PHONY: service +service: ## Install, enable and start the systemd service (needs sudo) + @sed -e 's|__USER__|$(USER)|g' \ + -e 's|__PROJECT_DIR__|$(PROJECT_DIR)|g' \ + -e 's|__UV__|$(UV)|g' \ + -e 's|__PORT__|$(PORT)|g' \ + deploy/osiris.service.template > /tmp/$(SERVICE).service + sudo install -m 644 /tmp/$(SERVICE).service $(UNIT_FILE) + @rm -f /tmp/$(SERVICE).service + sudo systemctl daemon-reload + sudo systemctl enable --now $(SERVICE) + @sudo systemctl --no-pager --lines=0 status $(SERVICE) || true + +.PHONY: restart +restart: ## Restart the service (do this after changing .env) + sudo systemctl restart $(SERVICE) + +.PHONY: logs +logs: ## Follow the service log + journalctl -u $(SERVICE) -f + +.PHONY: service-uninstall +service-uninstall: ## Stop and remove the systemd service + -sudo systemctl disable --now $(SERVICE) + sudo rm -f $(UNIT_FILE) + sudo systemctl daemon-reload + +# --- cron ------------------------------------------------------------------ + +# The script path is the marker: it identifies our line without needing a literal +# '#' comment, which make would strip as a comment of its own. +.PHONY: cron +cron: ## Install the reminder cron job (every 10 minutes, idempotent) + @chmod +x $(REMINDER_SH) + @( crontab -l 2>/dev/null | grep -vF '$(REMINDER_SH)' ; \ + echo "$(CRON_SCHED) $(REMINDER_SH) >> $(CRON_LOG) 2>&1" ) | crontab - + @echo "Installed:" + @crontab -l | grep -F '$(REMINDER_SH)' + +.PHONY: cron-remove +cron-remove: ## Remove the reminder cron job + @crontab -l 2>/dev/null | grep -vF '$(REMINDER_SH)' | crontab - + @echo "Removed." + +.PHONY: cron-test +cron-test: ## Run the reminder job once, reporting what it would send + $(REMINDER_SH) --dry-run + +# --- Development ----------------------------------------------------------- + +.PHONY: run +run: ## Run the development server + $(MANAGE) runserver $(PORT) + +.PHONY: test +test: ## Run the test suite + $(MANAGE) test + +.PHONY: lint +lint: ## Check formatting and lints + $(UV) run ruff format --check . + $(UV) run ruff check . + +.PHONY: format +format: ## Reformat and autofix + $(UV) run ruff format . + $(UV) run ruff check --fix . + +.PHONY: deploy +deploy: install restart ## Pull dependencies, migrate, collect static, restart + @echo "Deployed." diff --git a/README.md b/README.md index 98add28..305de44 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,38 @@ API is there so the Flutter app can be a second client without backend changes. ## Setup -```bash -uv sync -uv run python manage.py migrate -uv run python manage.py createsuperuser # the only way to make an account: there is no registration -uv run python manage.py runserver -``` - -Then open — it redirects to the Today page. - -To reach it from your phone on the same network: +`make help` lists everything. On a fresh box: ```bash -DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runserver 0.0.0.0:8000 +make install # uv sync, write .env (fresh SECRET_KEY + VAPID keys), migrate, collectstatic +make superuser # the only way to make an account: there is no registration +make service # install + start the systemd unit (gunicorn on 127.0.0.1:8000) — needs sudo +make cron # reminder job, every 10 minutes ``` +Review `DJANGO_ALLOWED_HOSTS` and `DJANGO_CSRF_TRUSTED_ORIGINS` in `.env` before +going live, and `make restart` after any change to it — the settings are read at +startup, so an edited `.env` does nothing until the service restarts. + +Put a TLS-terminating proxy (Caddy, nginx, a Cloudflare tunnel) in front of +port 8000; the app itself is HTTP-only and bound to localhost. HTTPS is not +optional if you want reminders — see below. + +Day to day: + +```bash +make run # development server on :8000 +make test +make lint # or: make format +make deploy # sync + migrate + collectstatic + restart +make logs # journalctl -u osiris -f +make cron-test # what the reminder job would send right now +``` + +`.env` is sourced by a shell wrapper, so its values must stay unquoted and +shell-safe. `make env` generates the secret from a URL-safe alphabet for exactly +that reason — a hand-written key containing `)` or `$` would break the cron job. + ## Using it 1. **Admin → Medications → Add**: create a medication and give it one row per diff --git a/deploy/osiris.service.template b/deploy/osiris.service.template new file mode 100644 index 0000000..857c4a6 --- /dev/null +++ b/deploy/osiris.service.template @@ -0,0 +1,28 @@ +[Unit] +Description=Osiris — cat medication and pulse tracking +After=network.target + +[Service] +Type=notify +User=__USER__ +WorkingDirectory=__PROJECT_DIR__ +EnvironmentFile=__PROJECT_DIR__/.env +# --no-sync: 'make install' already synced. Without it, uv would try to resolve +# dependencies at boot, which needs the network and can fail at the worst moment. +ExecStart=__UV__ run --no-sync gunicorn osiris.wsgi:application \ + --bind 127.0.0.1:__PORT__ \ + --workers 2 \ + --access-logfile - \ + --error-logfile - +Restart=on-failure +RestartSec=5s + +# SQLite and the media/static dirs are the only things it needs to write. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=read-only +ReadWritePaths=__PROJECT_DIR__ + +[Install] +WantedBy=multi-user.target diff --git a/deploy/send-reminders.sh b/deploy/send-reminders.sh new file mode 100755 index 0000000..1b3b0c7 --- /dev/null +++ b/deploy/send-reminders.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Wrapper for the reminder cron job. Cron runs with almost no environment, so the +# settings (and the VAPID keys) have to be loaded explicitly. +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_DIR" + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +exec "${UV:-uv}" run --no-sync python manage.py send_reminders "$@" diff --git a/pyproject.toml b/pyproject.toml index 266eff4..0171d10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "django>=5.2", "django-ninja>=1.4", + "gunicorn>=26.0.0", "pywebpush>=2.3.0", "whitenoise>=6.12.0", ] diff --git a/uv.lock b/uv.lock index cc7e4a7..7ab7c29 100644 --- a/uv.lock +++ b/uv.lock @@ -459,6 +459,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + [[package]] name = "http-ece" version = "1.2.1" @@ -623,6 +635,7 @@ source = { virtual = "." } dependencies = [ { name = "django" }, { name = "django-ninja" }, + { name = "gunicorn" }, { name = "pywebpush" }, { name = "whitenoise" }, ] @@ -638,6 +651,7 @@ dev = [ requires-dist = [ { name = "django", specifier = ">=5.2" }, { name = "django-ninja", specifier = ">=1.4" }, + { name = "gunicorn", specifier = ">=26.0.0" }, { name = "pywebpush", specifier = ">=2.3.0" }, { name = "whitenoise", specifier = ">=6.12.0" }, ] @@ -649,6 +663,15 @@ dev = [ { name = "ruff", specifier = ">=0.15.21" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "parso" version = "0.8.7"