Phase 5: Gitea Actions, Betriebs-Compose und README

Ein Versions-Tag (v*) löst den Bau aus, ein Push auf main nicht - so bleibt
das Zusammenführen von Branches folgenlos und ein Release ist ein bewusster
Schritt. Die CI baut, testet das FERTIGE Image und veröffentlicht erst
danach; "latest" nur bei echten Tags.

Beim Durchspielen des Prüfschritts zwei Fehler gefunden, die beide erst in
der CI aufgefallen wären - und einer davon gar nicht:

1. "docker exec" ohne -i hängt kein stdin an. Das Here-Dokument lief damit
   ins Leere, python bekam ein leeres Programm, tat nichts und meldete
   Erfolg. Ein Test, der nie hätte fehlschlagen können. Lokal nachgestellt
   und mit -i behoben.
2. Die YAML war ungültig: eingebettete Python-Blöcke standen links vom
   Einzug und brachen den run-Block auf. Das Prüfskript liegt jetzt als
   scripts/image-pruefen.py vor - dadurch lässt es sich auch lokal gegen
   ein gebautes Image laufen lassen - und die Warteschleife ist einzeilig.

Aus der Kantone-App übernommen: github.ref_name geht über env in die
Shell statt direkt als Ausdruck (Tag-Namen dürfen " und $ enthalten, und
der Job hält ein Registry-Token), Anmeldung per --password-stdin, docker
logout mit if: always(), Rechte auf contents:read und packages:write.

docker-compose.betrieb.yml liefert bewusst KEIN Passwort mit: ohne eigenes
bleibt der Erfassungsbereich gesperrt. Genau das war die schwerste Lücke
der Kantone-App.

Ausserdem fehlte eine .dockerignore - der ganze Kontext samt .git und .env
ging an den Docker-Daemon, obwohl das Dockerfile nur gezielt kopiert.

81 Tests grün, der CI-Prüfschritt lokal gegen das gebaute Image
durchgespielt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
This commit is contained in:
2026-08-30 08:40:57 +02:00
co-authored by Claude Opus 5
parent adedaa9a0d
commit 31f59fd3c8
6 changed files with 487 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Was nicht in den Build-Kontext gehört.
#
# Das Dockerfile kopiert ohnehin nur gezielt einzelne Verzeichnisse, es
# landet also nichts davon im Image. Aber der gesamte Kontext wird an den
# Docker-Daemon übertragen - und .env enthält das Admin-Passwort.
.env
.git
.gitea
.github
.claude
# Laufzeitdaten (im Betrieb Volumes)
data/
uploads/
*.sqlite
*.sqlite3
# Entwicklung
tests/
.pytest_cache/
.venv/
venv/
__pycache__/
*.py[cod]
# Wird im Build erzeugt bzw. nur dort gebraucht
tailwindcss
Plan.md
projekt.md
README.md
+12
View File
@@ -0,0 +1,12 @@
# Vorlage für die lokale Entwicklung: als ".env" kopieren und anpassen.
# Die echte .env gehört nicht ins Repository (siehe .gitignore).
# Passwort fürs Erfassen. Leer = Erfassungsbereich gesperrt.
ADMIN_PASSWORD=bitte-eigenes-setzen
# Signiert das Sitzungs-Cookie. Erzeugen mit:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=nur-lokal-bitte-im-betrieb-ersetzen
# Nur fürs Testen über http://localhost. Im Betrieb hinter HTTPS: true
COOKIE_HTTPS_ONLY=false
+152
View File
@@ -0,0 +1,152 @@
name: Docker-Image bauen
# Baut das Image, sobald ein Versions-Tag (v1.2.3) gepusht wird, und legt es
# in der eingebauten Container-Registry von Gitea ab. Bewusst NICHT bei
# jedem Push auf main: so bleibt das Zusammenführen von Branches folgenlos
# und ein Release ist ein bewusster Schritt.
on:
push:
tags:
- 'v*'
workflow_dispatch:
env:
REGISTRY: gitea.boing86.myds.me
IMAGE_NAME: docker/kleiderboerse
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Quellcode auschecken
uses: actions/checkout@v4
- name: Tests
run: |
set -eu
docker run --rm -v "$PWD":/app -w /app python:3.11-slim bash -c '
pip install --quiet -r requirements-dev.txt
python -m pytest -q
'
- name: Version bestimmen
id: meta
env:
# Bewusst über die Umgebung statt direkt als ${{ ... }} im Skript:
# Ausdrücke werden vor der Shell eingesetzt, und Tag-Namen dürfen
# Zeichen wie " oder $ enthalten. Als Variable kann daraus kein
# zusätzlicher Befehl werden.
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
run: |
set -eu
if [ "$REF_TYPE" = "tag" ]; then
VERSION="$REF_NAME"
VERSION="${VERSION#v}" # v1.2.3 -> 1.2.3
IS_RELEASE=true
else
VERSION="manual-$(date +%Y%m%d-%H%M%S)"
IS_RELEASE=false
fi
# Die Registry behandelt Namen case-insensitiv -> konsequent klein.
VERSION="$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
# Nur bei echten Tags eine Version in die Fusszeile schreiben; bei
# manuellen Läufen bleibt sie leer und es erscheint der Commit.
if [ "$IS_RELEASE" = "true" ]; then
echo "tag_version=$VERSION" >> "$GITHUB_OUTPUT"
else
echo "tag_version=" >> "$GITHUB_OUTPUT"
fi
echo "Version: $VERSION (Release: $IS_RELEASE)"
- name: Image bauen
run: |
set -eu
docker build \
--tag "$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}" \
--build-arg "APP_VERSION=${{ steps.meta.outputs.tag_version }}" \
--build-arg "APP_REVISION=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \
.
# Prüft das fertige Image, bevor irgendetwas veröffentlicht wird.
# Geprüft wird im Container selbst (kein Port-Mapping nötig, damit der
# Test unabhängig vom Netzwerk-Setup des Runners funktioniert).
- name: Image testen
run: |
set -eu
BILD="$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}"
CID=$(docker run -d -e ADMIN_PASSWORD=pruef -e SECRET_KEY=pruef "$BILD")
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
for i in $(seq 1 30); do
# Einzeilig: ein mehrzeiliger String bräche den YAML-Block auf,
# weil dessen Fortsetzung links vom Einzug stünde.
if docker exec "$CID" python -c "import urllib.request as u; u.urlopen('http://localhost:8000/health')" 2>/dev/null; then
break
fi
if [ "$i" = "30" ]; then
echo "Anwendung im Container nicht erreichbar geworden:"
docker logs "$CID"
exit 1
fi
sleep 1
done
# Stammdaten: ohne Kategorien und Grössen lässt sich nichts
# erfassen. Untergrenzen bewusst unter dem Ist-Stand, damit der
# Test nicht bei jeder Ergänzung anschlägt.
# -i ist zwingend: ohne angehängtes stdin bekommt "python -" ein
# leeres Programm, tut nichts und meldet Erfolg. Der Test könnte
# dann nie fehlschlagen.
docker exec -i "$CID" python - < scripts/image-pruefen.py
# Das CSS muss im Image liegen - es entsteht in einer eigenen
# Build-Stufe und würde bei einem Fehler dort still fehlen.
docker exec "$CID" test -s /app/app/static/style.css
echo "style.css vorhanden."
- name: An Registry anmelden
env:
# Optional: eigenes Token/Konto als Repo-Secret hinterlegen, falls
# der automatische GITEA_TOKEN nicht in die Registry schreiben darf.
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
AUTO_TOKEN: ${{ secrets.GITEA_TOKEN }}
AKTEUR: ${{ github.actor }}
run: |
set -eu
TOKEN="${REGISTRY_TOKEN:-$AUTO_TOKEN}"
BENUTZER="${REGISTRY_USER:-$AKTEUR}"
if [ -z "$TOKEN" ]; then
echo "Kein Token verfügbar: REGISTRY_TOKEN als Repo-Secret hinterlegen."
exit 1
fi
echo "Anmeldung an $REGISTRY als $BENUTZER"
# --password-stdin: so steht das Token weder in der Prozessliste
# noch im Protokoll.
echo "$TOKEN" | docker login "$REGISTRY" -u "$BENUTZER" --password-stdin
- name: Image veröffentlichen
run: |
set -eu
docker push "$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}"
# "latest" nur für echte Versions-Tags, nicht für manuelle Läufe.
if [ "${{ steps.meta.outputs.is_release }}" = "true" ]; then
docker tag "$REGISTRY/$IMAGE_NAME:${{ steps.meta.outputs.version }}" \
"$REGISTRY/$IMAGE_NAME:latest"
docker push "$REGISTRY/$IMAGE_NAME:latest"
fi
- name: Abmelden
if: always()
run: docker logout "$REGISTRY" || true
+182
View File
@@ -0,0 +1,182 @@
# Kinderkleider-Börse
Zu klein gewordene Kinderkleidung fotografieren, in einer Galerie zeigen und
an Familie, Freunde und Nachbarn weitergeben. Wer etwas brauchen kann,
reserviert es mit einem Klick ohne Konto, ohne Anmeldung.
Fürs Erfassen genügt das Smartphone: alles fotografieren, alle Fotos auf
einmal hochladen, die Angaben in Ruhe nachtragen.
---
## In vier Schritten
**1.** Einen leeren Ordner anlegen, zum Beispiel `kleiderboerse`.
**2.** Darin eine Datei namens `docker-compose.yml` anlegen und den Inhalt
von [`docker-compose.betrieb.yml`](docker-compose.betrieb.yml) hineinkopieren.
**3.** Zwei Werte eintragen:
```yaml
ADMIN_PASSWORD: "dein-passwort"
SECRET_KEY: "..."
```
Den `SECRET_KEY` erzeugen:
```bash
docker run --rm python:3.11-slim python -c "import secrets; print(secrets.token_urlsafe(48))"
```
Bleibt `ADMIN_PASSWORD` leer, ist der Erfassungsbereich gesperrt die
Galerie funktioniert trotzdem. Absichtlich ist kein Passwort vorgegeben:
ein mitgeliefertes stünde in dieser Anleitung und wäre damit bekannt.
**4.** Starten und öffnen:
```bash
docker compose up -d
```
→ <http://localhost:8090>
---
## Erfassen
Nach dem Anmelden gibt es zwei Wege:
**Viele auf einmal** der Weg für den Erstbestand. Zuerst alles mit der
Kamera-App fotografieren, dann alle Fotos zusammen hochladen. Pro Foto
entsteht ein Entwurf; unter *Entwürfe* trägst du danach Grösse und Kategorie
nach. Grösse, Kategorie, Saison und Zustand werden dabei vom zuletzt
gespeicherten Teil übernommen beim Abarbeiten einer Kiste sind das vier
Felder weniger pro Stück.
**Einzeln** Formular mit Foto, für spätere Nachträge.
Der Titel ist freiwillig. Bleibt er leer, steht dort automatisch Kategorie
und Grösse, also zum Beispiel „Jacken 98/104". Wird die Grösse später
korrigiert, ändert sich der Titel mit.
## Reservieren
Besucher brauchen kein Konto. Nach dem Reservieren erscheint ein Link, mit
dem sich die eigene Reservierung wieder aufheben lässt dieser Link sollte
aufbewahrt werden. Ohne ihn kann nur der Betreiber die Reservierung lösen.
Der Name der reservierenden Person erscheint **nicht** in der Galerie, dort
steht nur „reserviert". Sichtbar ist er ausschliesslich nach der Anmeldung.
---
## Betrieb
### Aus dem Internet erreichbar machen
Die App gehört hinter einen Reverse-Proxy, der HTTPS beendet. Wichtig dabei:
- `X-Forwarded-Proto` und `X-Forwarded-For` weiterreichen. Ohne das erkennt
die App nicht, dass der Browser verschlüsselt verbunden ist, und alle
Rate-Limits zählen auf die Adresse des Proxys ein einzelner Besucher
sperrte damit alle anderen aus.
- `COOKIE_HTTPS_ONLY` auf `"true"` lassen.
Die Galerie ist frei zugänglich, wird aber per `noindex` und `robots.txt`
von Suchmaschinen ferngehalten: sonst wären Fotos und Texte noch auffindbar,
wenn die Börse längst abgeräumt ist.
### Sicherung
Zwei Volumes enthalten alles, was nicht im Code steht:
| Volume | Inhalt |
|---|---|
| `kleiderboerse-db` | Datenbank (`/data`) |
| `kleiderboerse-bilder` | hochgeladene Fotos (`/uploads`) |
```bash
docker run --rm -v kleiderboerse-db:/d -v "$PWD":/sicherung alpine \
tar czf /sicherung/db.tar.gz -C /d .
docker run --rm -v kleiderboerse-bilder:/b -v "$PWD":/sicherung alpine \
tar czf /sicherung/bilder.tar.gz -C /b .
```
### Einstellungen
Alle über Umgebungsvariablen, keine im Image:
| Variable | Standard | Bedeutung |
|---|---|---|
| `ADMIN_PASSWORD` | | Passwort fürs Erfassen. Leer = gesperrt. |
| `ADMIN_PASSWORD_HASH` | | Alternative: fertiger bcrypt-Hash. Hat Vorrang. |
| `SECRET_KEY` | | Signiert das Sitzungs-Cookie. Ohne festen Wert nach jedem Neustart abgemeldet. |
| `COOKIE_HTTPS_ONLY` | `true` | Sitzungs-Cookie nur über HTTPS. |
| `RATE_LIMIT_RESERVE` | `5/hour` | Reservierungen pro IP. |
| `RATE_LIMIT_LOGIN` | `10/hour` | Anmeldeversuche pro IP. |
| `SESSION_IDLE_SECONDS` | `7200` | Abmeldung nach Ruhezeit. |
| `MAX_UPLOAD_BYTES` | `10485760` | Grösse eines einzelnen Fotos. |
| `DATABASE_PATH` | `/data/kleiderboerse.sqlite` | Ort der Datenbank. |
| `UPLOAD_PATH` | `/uploads` | Ort der Fotos. |
---
## Entwickeln
```bash
cp .env-example .env # dann ADMIN_PASSWORD darin anpassen
docker compose up -d --build
```
→ <http://localhost:8090>, API-Oberfläche unter `/docs`
Tests:
```bash
docker run --rm -v "$PWD":/app -w /app python:3.11-slim bash -c \
'pip install -q -r requirements-dev.txt && python -m pytest -q'
```
Datenbank-Migration nach einer Modelländerung:
```bash
alembic revision --autogenerate -m "Beschreibung"
```
### Aufbau
Ein Dienst, eine SQLite-Datei. FastAPI liefert sowohl die REST-API unter
`/api/v1/…` als auch die Seiten aus; HTMX tauscht beim Filtern nur die Liste
aus. Tailwind wird im Docker-Build über die Standalone-Binärdatei übersetzt
kein Node.js nötig.
```
app/
├── main.py Einstiegspunkt, Middleware, Sicherheits-Header
├── config.py Einstellungen aus Umgebungsvariablen
├── models.py Datenmodelle
├── crud.py Datenbankzugriffe
├── security.py Anmeldung, Sitzung, CSRF, Rate-Limits
├── images.py Upload prüfen und nach WebP wandeln
├── routers/ API- und Seiten-Routen
└── templates/ Jinja2-Vorlagen
```
Fremde Dateien (HTMX, Swagger, Schriften) liegen bewusst lokal im
Repository statt von einem CDN geladen zu werden: die Content-Security-Policy
erlaubt nur `script-src 'self'`, und ein CDN-Aufruf gäbe die IP-Adresse jedes
Besuchers an einen Dritten weiter.
## Veröffentlichen
Ein Versions-Tag löst den Bau des Images aus:
```bash
git tag -a v1.0.0 -m "Version 1.0.0 ..."
git push origin v1.0.0
```
Die CI baut, **testet das fertige Image** und stellt es erst danach in die
Registry. `latest` wird nur bei echten Versions-Tags gesetzt. Ein Push auf
`main` löst nichts aus.
+53
View File
@@ -0,0 +1,53 @@
# Fertiges Image starten ohne Quellcode, ohne selbst zu bauen.
#
# So geht's:
# 1. Diese Datei als "docker-compose.yml" in einen leeren Ordner legen
# 2. Unten ein eigenes Passwort und einen eigenen SECRET_KEY eintragen
# 3. Im Ordner ausführen: docker compose up -d
# 4. Im Browser öffnen: http://localhost:8090
#
# Beenden: docker compose down
# Aktualisieren: docker compose pull && docker compose up -d
services:
app:
image: gitea.boing86.myds.me/docker/kleiderboerse:latest
restart: unless-stopped
ports:
# links der Port am eigenen Rechner, rechts der im Container.
# Ist 8090 schon belegt, einfach auf z.B. "8091:8000" ändern.
- "8090:8000"
environment:
# Passwort fürs Erfassen. Solange hier nichts steht, ist der
# Erfassungsbereich gesperrt; Galerie und Reservieren funktionieren
# trotzdem. Bewusst leer ausgeliefert: ein mitgeliefertes Passwort
# stünde in dieser Anleitung und wäre damit allgemein bekannt.
ADMIN_PASSWORD: ""
# Signiert das Sitzungs-Cookie. Ohne festen Wert ist man nach jedem
# Neustart abgemeldet. Einen erzeugen mit:
# docker run --rm python:3.11-slim python -c \
# "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY: ""
# Die App läuft hinter einem Reverse-Proxy mit HTTPS. Auf "false" nur
# zum Testen über http://localhost - sonst ginge das Sitzungs-Cookie
# auch über unverschlüsselte Verbindungen mit.
COOKIE_HTTPS_ONLY: "true"
# Reservieren ist ohne Anmeldung möglich. Diese Grenze ist damit das
# Einzige, was zwischen einem Skript und dem ganzen Bestand steht.
RATE_LIMIT_RESERVE: "5/hour"
RATE_LIMIT_LOGIN: "10/hour"
volumes:
# Hier liegen Datenbank und Bilder. Ohne diese beiden Zeilen wäre nach
# einem Neustart alles weg.
- kleiderboerse-db:/data
- kleiderboerse-bilder:/uploads
volumes:
kleiderboerse-db:
kleiderboerse-bilder:
+57
View File
@@ -0,0 +1,57 @@
"""Prüft das fertige Image, bevor es veröffentlicht wird.
Wird von der CI in den Container gepipt:
docker exec -i "$CID" python - < scripts/image-pruefen.py
Bewusst eine eigene Datei statt eines Here-Dokuments im Workflow: ein
unindentierter Block bricht den YAML-Block auf, und als Datei lässt sich
das Ganze auch lokal gegen ein gebautes Image laufen lassen.
Die Untergrenzen liegen bewusst unter dem Ist-Stand, damit der Test nicht
bei jeder zusätzlichen Kategorie anschlägt.
"""
import json
import sys
import urllib.error
import urllib.request
BASIS = "http://localhost:8000"
def hole(pfad: str):
with urllib.request.urlopen(BASIS + pfad) as antwort:
return json.load(antwort)
def main() -> None:
kategorien = hole("/api/v1/categories")
groessen = hole("/api/v1/sizes")
print(f"Kategorien: {len(kategorien)}, Grössen: {len(groessen)}")
assert len(kategorien) >= 5, "zu wenige Kategorien - Stammdaten fehlen?"
assert len(groessen) >= 20, "zu wenige Grössen - Stammdaten fehlen?"
with urllib.request.urlopen(BASIS + "/") as antwort:
seite = antwort.read().decode()
assert "Kleider-Börse" in seite, "Galerie wird nicht ausgeliefert"
# Das CSS entsteht in einer eigenen Build-Stufe und würde bei einem
# Fehler dort still fehlen - die Seite käme dann ohne Gestaltung.
assert "/static/style.css" in seite, "kein CSS eingebunden"
print("Galerie und CSS: ok")
# Ohne Anmeldung darf nichts geschrieben werden.
try:
urllib.request.urlopen(urllib.request.Request(
BASIS + "/api/v1/items", method="POST",
data=b'{"size":"80"}',
headers={"Content-Type": "application/json"},
))
except urllib.error.HTTPError as fehler:
assert fehler.code == 401, f"ergab {fehler.code}, erwartet 401"
else:
sys.exit("Schreiben war ohne Anmeldung möglich!")
print("Schreibschutz greift.")
if __name__ == "__main__":
main()