Compare commits
3 Commits
2e9ee3e014
...
399913abe3
| Author | SHA1 | Date | |
|---|---|---|---|
| 399913abe3 | |||
| 94bfc60fc4 | |||
| 88ef0d6943 |
5
.env
5
.env
@@ -1,5 +0,0 @@
|
|||||||
PG_DSN=postgresql://postgres:DFGk5s9H21lKao2K@136.243.41.58:7777/meles
|
|
||||||
S3_BUCKET=trapper-meles
|
|
||||||
S3_ACCESS_KEY=UG950FCGWFYRXSXXVUAH
|
|
||||||
S3_SECRET_KEY=rXpspNhboZY6zNZi7djjq2QaXPA4uwsO9jXf4AXk
|
|
||||||
S3_ENDPOINT=https://fsn1.your-objectstorage.com/
|
|
||||||
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# .env.example - Vorlage fuer die Secrets.
|
||||||
|
#
|
||||||
|
# Auf dem Server eine echte .env daraus erzeugen und mit den NEU ROTIERTEN
|
||||||
|
# Werten befuellen:
|
||||||
|
# cp .env.example .env
|
||||||
|
# chmod 600 .env
|
||||||
|
# Die echte .env wird durch .gitignore ausgeschlossen und darf NIE eingecheckt werden.
|
||||||
|
|
||||||
|
# --- Hetzner Object Storage (S3) ---
|
||||||
|
S3_ENDPOINT=https://fsn1.your-objectstorage.com/
|
||||||
|
S3_BUCKET=trapper-meles
|
||||||
|
S3_ACCESS_KEY=__NEUEN_ACCESS_KEY_EINTRAGEN__
|
||||||
|
S3_SECRET_KEY=__NEUEN_SECRET_KEY_EINTRAGEN__
|
||||||
|
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
# TLS erzwingen (sslmode=require). Host/Port zeigen auf den NEUEN DB-Zugang
|
||||||
|
# (idealerweise ueber privates Netz / SSH-Tunnel, nicht oeffentlich erreichbar).
|
||||||
|
PG_DSN=postgresql://USER:PASSWORT@HOST:PORT/meles?sslmode=require
|
||||||
|
|
||||||
|
# --- DeerMapper API (nur noetig, wenn app.enable_deermapper_api: true) ---
|
||||||
|
DEERMAPPER_API_URL=https://webapp.deermapper.net/api/icu
|
||||||
|
DEERMAPPER_API_KEY=__NEUEN_API_KEY_EINTRAGEN__
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,3 +1,11 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
|
# Secrets - NIEMALS einchecken. Echte Werte leben nur in der lokalen .env auf dem Server.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Optionale lokale Config-Overrides (falls verwendet)
|
||||||
|
config.local.yaml
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ WORKDIR /app
|
|||||||
COPY requirements.txt /app/requirements.txt
|
COPY requirements.txt /app/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||||
|
|
||||||
|
# Code + nicht-geheime Konfiguration ins Image (Secrets kommen zur Laufzeit via ENV).
|
||||||
COPY main.py /app/main.py
|
COPY main.py /app/main.py
|
||||||
|
COPY config.yaml /app/config.yaml
|
||||||
|
|
||||||
|
# Haertung: nicht als root laufen.
|
||||||
|
RUN useradd --create-home --uid 10001 appuser
|
||||||
|
USER appuser
|
||||||
|
|
||||||
CMD ["python", "/app/main.py"]
|
CMD ["python", "/app/main.py"]
|
||||||
|
|||||||
169
MIGRATION.md
Normal file
169
MIGRATION.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Migration & Secret-Rotation - Runbook
|
||||||
|
|
||||||
|
Ziel: `melesICUmover` sicher vom **Altserver** auf den **neuen Server** umziehen,
|
||||||
|
alle kompromittierten Zugangsdaten **rotieren**, den Altserver **stilllegen** und
|
||||||
|
die Secrets aus der **Git-History** entfernen.
|
||||||
|
|
||||||
|
> Kontext: Der Poller haelt keine eigenen Daten - Bilder liegen in Hetzner S3
|
||||||
|
> (`trapper-meles`), der Zustand in PostgreSQL. "Migration" heisst also: den
|
||||||
|
> **Worker-Container** umziehen und die **Credentials** erneuern. Es gibt keine
|
||||||
|
> Datenmigration.
|
||||||
|
|
||||||
|
## Warum rotieren (nicht nur entfernen)?
|
||||||
|
|
||||||
|
Folgende Secrets lagen im Klartext in `config.yaml` und `.env` **und in der
|
||||||
|
Git-History** (Commits `400f8a5`/`b35394c`) auf `git.meles.eu`. Sie gelten als
|
||||||
|
kompromittiert und muessen ersetzt werden:
|
||||||
|
|
||||||
|
- S3 Access Key + Secret Key (Hetzner Object Storage)
|
||||||
|
- PostgreSQL-Passwort (`postgres@136.243.41.58:7777`)
|
||||||
|
- DeerMapper API-Key
|
||||||
|
|
||||||
|
Das Bereinigen des Codes allein schuetzt nicht - die alten Werte bleiben gueltig,
|
||||||
|
bis sie widerrufen/geaendert werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 - Vorbereitung (kein Ausfall)
|
||||||
|
|
||||||
|
1. Auf dem **neuen Server** Docker + Compose pruefen/installieren:
|
||||||
|
```bash
|
||||||
|
docker --version && docker compose version || curl -fsSL https://get.docker.com | sh
|
||||||
|
```
|
||||||
|
2. Repo auf den neuen Server holen (bereinigter Stand, ohne Secrets):
|
||||||
|
```bash
|
||||||
|
git clone ssh://git.meles.eu/meles2/melesICUmover.git
|
||||||
|
cd melesICUmover
|
||||||
|
```
|
||||||
|
3. `.env` anlegen (noch NICHT starten):
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 1 - S3-Key rotieren (kein Ausfall, additiv)
|
||||||
|
|
||||||
|
Hetzner erlaubt mehrere S3-Credentials pro Projekt. Neuen Key **zusaetzlich**
|
||||||
|
anlegen, alten vorerst aktiv lassen:
|
||||||
|
|
||||||
|
1. In der Hetzner Console -> Object Storage -> neuen S3-Zugang (Access/Secret) erzeugen.
|
||||||
|
2. In der `.env` auf dem neuen Server `S3_ACCESS_KEY` / `S3_SECRET_KEY` eintragen.
|
||||||
|
|
||||||
|
## Phase 2 - DeerMapper API-Key rotieren
|
||||||
|
|
||||||
|
Nur relevant, wenn `enable_deermapper_api: true` (aktuell `false`). Falls genutzt:
|
||||||
|
neuen Key beim DeerMapper-Betreiber anfordern und in die `.env` eintragen. Sonst
|
||||||
|
Feld leer lassen.
|
||||||
|
|
||||||
|
## Phase 3 - Cutover (kurzer, geplanter Ausfall)
|
||||||
|
|
||||||
|
Der PostgreSQL-Passwortwechsel trennt sofort den Altserver - daher hier gebuendelt:
|
||||||
|
|
||||||
|
1. **Altserver-Poller stoppen** (kein Doppelbetrieb auf denselben Bucket/DB):
|
||||||
|
```bash
|
||||||
|
# auf dem ALTSERVER
|
||||||
|
docker compose down # bzw. docker stop melesicumover
|
||||||
|
```
|
||||||
|
2. **PostgreSQL-Passwort aendern** (Beispiel; besser: eigene DB-Rolle fuer den Dienst):
|
||||||
|
```sql
|
||||||
|
ALTER USER postgres WITH PASSWORD '<NEUES_STARKES_PASSWORT>';
|
||||||
|
-- Empfohlen stattdessen: dedizierte Rolle mit minimalen Rechten auf remote_cam.*
|
||||||
|
-- CREATE ROLE icu_mover LOGIN PASSWORD '...';
|
||||||
|
-- GRANT USAGE ON SCHEMA remote_cam TO icu_mover;
|
||||||
|
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA remote_cam TO icu_mover;
|
||||||
|
```
|
||||||
|
3. `PG_DSN` in der `.env` des neuen Servers setzen - **mit `?sslmode=require`** und,
|
||||||
|
sofern moeglich, ueber privates Netz / SSH-Tunnel statt der oeffentlichen IP.
|
||||||
|
4. **Neuen Server starten und verifizieren:**
|
||||||
|
```bash
|
||||||
|
# auf dem NEUEN Server
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f # auf [RESULT] ...-Zeilen achten
|
||||||
|
```
|
||||||
|
Verifikation:
|
||||||
|
- Log zeigt verarbeitete UUIDs mit `result=success`.
|
||||||
|
- In S3 landen neue Objekte unter `icu/processed/` und `icu/thumbnails/`.
|
||||||
|
- `icu/entrance/` wird geleert (Cleanup laeuft).
|
||||||
|
- DB: `SELECT status, count(*) FROM remote_cam.import_job GROUP BY 1;`
|
||||||
|
|
||||||
|
## Phase 4 - Altzugaenge widerrufen
|
||||||
|
|
||||||
|
1. Alten S3-Key in der Hetzner Console **loeschen** (erst nachdem der neue Server
|
||||||
|
nachweislich laeuft).
|
||||||
|
2. Sicherstellen, dass der Altserver den neuen DB-Zugang **nicht** kennt.
|
||||||
|
|
||||||
|
## Phase 5 - DB-Zugang absichern (Haertung)
|
||||||
|
|
||||||
|
- PostgreSQL **nicht** auf `0.0.0.0:7777` oeffentlich anbieten. Firewall so setzen,
|
||||||
|
dass nur die IP des neuen Servers (oder ein privates Netz) den DB-Port erreicht.
|
||||||
|
- TLS erzwingen (`sslmode=require`), Hetzner Cloud Firewall bzw. `pg_hba.conf` pruefen.
|
||||||
|
|
||||||
|
## Phase 6 - Altserver stilllegen / neu aufsetzen
|
||||||
|
|
||||||
|
1. Sicherstellen: keine unersetzten Daten mehr lokal (der Poller hat keine).
|
||||||
|
2. Container + Images + lokale `.env`/`config.yaml` mit Secrets entfernen:
|
||||||
|
```bash
|
||||||
|
# auf dem ALTSERVER
|
||||||
|
docker compose down --rmi all --volumes
|
||||||
|
shred -u .env 2>/dev/null || rm -f .env
|
||||||
|
```
|
||||||
|
3. Server neu aufsetzen bzw. deprovisionieren. Falls neu installiert: alte
|
||||||
|
SSH-/Deploy-Keys, die auf `git.meles.eu` Zugriff hatten, am Git-Server widerrufen.
|
||||||
|
|
||||||
|
## Phase 7 - Git-History bereinigen (koordiniert, destruktiv)
|
||||||
|
|
||||||
|
Erst **nach** erfolgreicher Rotation - danach sind die alten Werte ohnehin wertlos,
|
||||||
|
aber sie sollen auch nicht mehr auffindbar sein.
|
||||||
|
|
||||||
|
> Warnung: Das schreibt die History um und erfordert `--force`-Push. Alle Personen
|
||||||
|
> mit Klon muessen anschliessend neu klonen. Vorher mit dem Team abstimmen.
|
||||||
|
|
||||||
|
Mit [git filter-repo](https://github.com/newren/git-filter-repo) (empfohlen):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# frisches Spiegel-Klon als Arbeitskopie
|
||||||
|
git clone ssh://git.meles.eu/meles2/melesICUmover.git repo-clean
|
||||||
|
cd repo-clean
|
||||||
|
|
||||||
|
# .env komplett aus der History entfernen
|
||||||
|
git filter-repo --path .env --invert-paths
|
||||||
|
|
||||||
|
# Secret-Werte auch aus historischen config.yaml-Versionen tilgen.
|
||||||
|
# Die vier ALTEN Werte NICHT hier ins Repo schreiben - die Datei liegt ausserhalb
|
||||||
|
# des Repos und wird nach dem Lauf geloescht. Werte aus der alten .env / History
|
||||||
|
# (git show 400f8a5:config.yaml) entnehmen:
|
||||||
|
cat > ../replacements.txt <<'EOF'
|
||||||
|
<ALTES_PG_PASSWORT>==>ENTFERNT
|
||||||
|
<ALTES_S3_SECRET_KEY>==>ENTFERNT
|
||||||
|
<ALTER_S3_ACCESS_KEY>==>ENTFERNT
|
||||||
|
<ALTER_DEERMAPPER_API_KEY>==>ENTFERNT
|
||||||
|
EOF
|
||||||
|
git filter-repo --replace-text ../replacements.txt
|
||||||
|
|
||||||
|
# Remote neu setzen und History ueberschreiben
|
||||||
|
git remote add origin ssh://git.meles.eu/meles2/melesICUmover.git
|
||||||
|
git push --force --all origin
|
||||||
|
git push --force --tags origin
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `replacements.txt` loeschen. Alternative: BFG Repo-Cleaner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Falls der neue Server Probleme macht, **bevor** Phase 4/6 abgeschlossen sind:
|
||||||
|
alten S3-Key noch aktiv lassen, altes PG-Passwort noch nicht wegwerfen, und den
|
||||||
|
Altserver-Poller wieder starten (`docker compose up -d`). Deshalb Altzugaenge erst
|
||||||
|
in Phase 4 widerrufen.
|
||||||
|
|
||||||
|
## Abschluss-Checkliste
|
||||||
|
|
||||||
|
- [ ] Neuer Server verarbeitet Bilder (`result=success`, S3 + DB aktualisiert)
|
||||||
|
- [ ] Neue S3-Keys aktiv, alte geloescht
|
||||||
|
- [ ] Neues PG-Passwort/-Rolle aktiv, `sslmode=require`, DB-Port nicht oeffentlich
|
||||||
|
- [ ] DeerMapper-Key rotiert (falls genutzt)
|
||||||
|
- [ ] Altserver-Poller gestoppt, `.env` sicher geloescht, Server neu aufgesetzt
|
||||||
|
- [ ] Alte SSH-/Deploy-Keys am Git-Server widerrufen
|
||||||
|
- [ ] Git-History bereinigt + force-push, Team informiert
|
||||||
|
- [ ] Repo enthaelt keine Secrets mehr (`git grep` auf HEAD + Stichprobe History)
|
||||||
46
README.md
46
README.md
@@ -1 +1,45 @@
|
|||||||
# melesICUmover
|
# melesICUmover
|
||||||
|
|
||||||
|
Dauer-Poller, der Wildkamera-Bilder aus einem Hetzner-S3-Bucket verarbeitet:
|
||||||
|
liest neue Bilder + `metadata.json` (+ optional `_categories.json`), extrahiert per
|
||||||
|
**OCR** (Tesseract) den Overlay-Zeitstempel, schreibt **EXIF**-Daten (exiftool),
|
||||||
|
legt verarbeitetes Bild + Thumbnail zurueck nach S3, persistiert alles nach
|
||||||
|
**PostgreSQL** (`remote_cam.*`), pusht optional an die **DeerMapper-API** und
|
||||||
|
raeumt den Entrance-Ordner auf. Zustand pro Bild in `remote_cam.import_job.status`.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Zweigeteilt - bewusst:
|
||||||
|
|
||||||
|
| Datei | Inhalt | Im Git? |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| `config.yaml` | Nicht-geheime App-Tunables + oeffentliche Endpunkte | **ja** (Template) |
|
||||||
|
| `.env` | Secrets: S3-Keys, `PG_DSN`, API-Key | **nein** (`.gitignore`) |
|
||||||
|
|
||||||
|
Secrets werden **ausschliesslich** ueber Umgebungsvariablen geladen; die Werte in
|
||||||
|
`.env` ueberschreiben bzw. ergaenzen `config.yaml`. Beim Start prueft die App die
|
||||||
|
Vollstaendigkeit und bricht mit klarer Meldung ab, falls ein Secret fehlt.
|
||||||
|
|
||||||
|
Unterstuetzte Umgebungsvariablen: `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`,
|
||||||
|
`S3_SECRET_KEY`, `PG_DSN`, `DEERMAPPER_API_URL`, `DEERMAPPER_API_KEY`, `CONFIG_YAML`,
|
||||||
|
`DEBUG`.
|
||||||
|
|
||||||
|
## Betrieb (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # danach echte, rotierte Werte eintragen
|
||||||
|
chmod 600 .env
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheitshinweise
|
||||||
|
|
||||||
|
- **Niemals** Secrets in `config.yaml` oder ins Git schreiben - nur in `.env`.
|
||||||
|
- `PG_DSN` mit `?sslmode=require`; die Datenbank sollte **nicht** oeffentlich
|
||||||
|
erreichbar sein (privates Netz / Firewall / SSH-Tunnel).
|
||||||
|
- Debug-Port (`5678`) ist standardmaessig aus; bei Bedarf nur an `127.0.0.1` binden.
|
||||||
|
- Nach Kompromittierung (z.B. Secrets waren im Git): **rotieren**, nicht nur entfernen.
|
||||||
|
|
||||||
|
Siehe [`MIGRATION.md`](MIGRATION.md) fuer Secret-Rotation, Umzug auf einen neuen
|
||||||
|
Server und Stilllegung des Altservers.
|
||||||
|
|||||||
18
config.yaml
18
config.yaml
@@ -1,16 +1,22 @@
|
|||||||
# config.yaml (Beispiel)
|
# config.yaml - Nicht-geheime Konfiguration (App-Tunables + oeffentliche Endpunkte).
|
||||||
|
#
|
||||||
|
# WICHTIG: Hier gehoeren KEINE Zugangsdaten hinein.
|
||||||
|
# Secrets kommen ausschliesslich aus Umgebungsvariablen (.env):
|
||||||
|
# S3_ACCESS_KEY, S3_SECRET_KEY, PG_DSN, DEERMAPPER_API_KEY
|
||||||
|
# (optional koennen auch S3_ENDPOINT / S3_BUCKET / DEERMAPPER_API_URL per ENV
|
||||||
|
# ueberschrieben werden.)
|
||||||
|
|
||||||
s3:
|
s3:
|
||||||
endpoint: "https://fsn1.your-objectstorage.com/"
|
endpoint: "https://fsn1.your-objectstorage.com/"
|
||||||
access_key: "UG950FCGWFYRXSXXVUAH"
|
|
||||||
secret_key: "rXpspNhboZY6zNZi7djjq2QaXPA4uwsO9jXf4AXk"
|
|
||||||
bucket: "trapper-meles"
|
bucket: "trapper-meles"
|
||||||
|
# access_key / secret_key -> via ENV (.env): S3_ACCESS_KEY / S3_SECRET_KEY
|
||||||
|
|
||||||
postgres:
|
postgres: {}
|
||||||
dsn: "postgresql://postgres:DFGk5s9H21lKao2K@136.243.41.58:7777/meles"
|
# dsn -> via ENV (.env): PG_DSN (inkl. ?sslmode=require)
|
||||||
|
|
||||||
deermapper-api:
|
deermapper-api:
|
||||||
base_url: "https://webapp.deermapper.net/api/icu"
|
base_url: "https://webapp.deermapper.net/api/icu"
|
||||||
apiKey: "695bc217-3b40-48bb-bb12-17fc5b08b320"
|
# apiKey -> via ENV (.env): DEERMAPPER_API_KEY
|
||||||
|
|
||||||
app:
|
app:
|
||||||
entrance_prefix: "icu/entrance/"
|
entrance_prefix: "icu/entrance/"
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: melesicumover
|
container_name: melesicumover
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Secrets werden aus der lokalen .env in die Container-Umgebung injiziert.
|
||||||
|
# Die .env liegt NICHT im Image und NICHT im Git (siehe .gitignore).
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
CONFIG_YAML: /app/config.yaml
|
CONFIG_YAML: /app/config.yaml
|
||||||
DEBUG: "0"
|
DEBUG: "0"
|
||||||
ports:
|
# Kein Bind-Mount des Quellcodes mehr (der Code steckt im Image) und
|
||||||
- "5678:5678"
|
# standardmaessig KEIN offener Debug-Port.
|
||||||
volumes:
|
#
|
||||||
- ./:/app
|
# Debugging nur bei Bedarf, lokal:
|
||||||
- ./config.yaml:/app/config.yaml:ro
|
# 1) DEBUG oben auf "1" setzen
|
||||||
|
# 2) folgende Zeilen einkommentieren - bindet debugpy NUR an localhost:
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:5678:5678"
|
||||||
|
|||||||
111
main.py
111
main.py
@@ -101,22 +101,22 @@ def load_config(path: str) -> AppConfig:
|
|||||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||||
return bool(value)
|
return bool(value)
|
||||||
|
|
||||||
s3 = raw["s3"]
|
s3 = raw.get("s3") or {}
|
||||||
pg = raw["postgres"]
|
pg = raw.get("postgres") or {}
|
||||||
app = raw.get("app", {})
|
app = raw.get("app") or {}
|
||||||
deermapper = raw.get("deermapper-api", raw.get("deermapper_api", {}))
|
deermapper = raw.get("deermapper-api") or raw.get("deermapper_api") or {}
|
||||||
default_workers = max(1, min(16, (os.cpu_count() or 2) * 2))
|
default_workers = max(1, min(16, (os.cpu_count() or 2) * 2))
|
||||||
parallel_workers = int(app.get("parallel_workers", default_workers))
|
parallel_workers = int(app.get("parallel_workers", default_workers))
|
||||||
parallel_workers = max(1, parallel_workers)
|
parallel_workers = max(1, parallel_workers)
|
||||||
s3_pool = int(app.get("s3_max_pool_connections", max(16, parallel_workers * 4)))
|
s3_pool = int(app.get("s3_max_pool_connections", max(16, parallel_workers * 4)))
|
||||||
s3_pool = max(10, s3_pool)
|
s3_pool = max(10, s3_pool)
|
||||||
|
|
||||||
return AppConfig(
|
cfg = AppConfig(
|
||||||
s3_endpoint=env_or("S3_ENDPOINT", s3["endpoint"]),
|
s3_endpoint=env_or("S3_ENDPOINT", s3.get("endpoint", "")),
|
||||||
s3_access_key=env_or("S3_ACCESS_KEY", s3["access_key"]),
|
s3_access_key=env_or("S3_ACCESS_KEY", s3.get("access_key", "")),
|
||||||
s3_secret_key=env_or("S3_SECRET_KEY", s3["secret_key"]),
|
s3_secret_key=env_or("S3_SECRET_KEY", s3.get("secret_key", "")),
|
||||||
s3_bucket=env_or("S3_BUCKET", s3["bucket"]),
|
s3_bucket=env_or("S3_BUCKET", s3.get("bucket", "")),
|
||||||
pg_dsn=env_or("PG_DSN", pg["dsn"]),
|
pg_dsn=env_or("PG_DSN", pg.get("dsn", "")),
|
||||||
entrance_prefix=app.get("entrance_prefix", "icu/entrance/"),
|
entrance_prefix=app.get("entrance_prefix", "icu/entrance/"),
|
||||||
processed_prefix=app.get("processed_prefix", "icu/processed/"),
|
processed_prefix=app.get("processed_prefix", "icu/processed/"),
|
||||||
thumb_prefix=app.get("thumb_prefix", "icu/thumbnails/"),
|
thumb_prefix=app.get("thumb_prefix", "icu/thumbnails/"),
|
||||||
@@ -138,6 +138,29 @@ def load_config(path: str) -> AppConfig:
|
|||||||
deermapper_api_image_field=app.get("deermapper_api_image_field", "image"),
|
deermapper_api_image_field=app.get("deermapper_api_image_field", "image"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fail-fast: Secrets kommen ausschliesslich aus der Umgebung (.env). Fehlen sie,
|
||||||
|
# bricht der Start mit klarer Meldung ab, statt spaeter kryptisch zu scheitern.
|
||||||
|
missing = []
|
||||||
|
if not cfg.s3_endpoint:
|
||||||
|
missing.append("S3_ENDPOINT (oder s3.endpoint)")
|
||||||
|
if not cfg.s3_access_key:
|
||||||
|
missing.append("S3_ACCESS_KEY")
|
||||||
|
if not cfg.s3_secret_key:
|
||||||
|
missing.append("S3_SECRET_KEY")
|
||||||
|
if not cfg.s3_bucket:
|
||||||
|
missing.append("S3_BUCKET (oder s3.bucket)")
|
||||||
|
if not cfg.pg_dsn:
|
||||||
|
missing.append("PG_DSN")
|
||||||
|
if cfg.enable_deermapper_api and not cfg.deermapper_api_key:
|
||||||
|
missing.append("DEERMAPPER_API_KEY (enable_deermapper_api ist aktiv)")
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(
|
||||||
|
"Fehlende Konfiguration/Secrets: " + ", ".join(missing)
|
||||||
|
+ ". Bitte in der .env / als Umgebungsvariablen setzen."
|
||||||
|
)
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
# -----------------------
|
# -----------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -200,13 +223,18 @@ def ocr_extract_timestamp(jpg_bytes: bytes, crop_w_frac: float, crop_h_frac: flo
|
|||||||
|
|
||||||
bw = gray.point(lambda p: 255 if p > 180 else 0)
|
bw = gray.point(lambda p: 255 if p > 180 else 0)
|
||||||
|
|
||||||
cfg = r'--oem 3 --psm 6 -c tessedit_char_whitelist=0123456789:- '
|
tess_cfg = r'--oem 3 --psm 6 -c tessedit_char_whitelist=0123456789:- '
|
||||||
text = pytesseract.image_to_string(bw, config=cfg).strip()
|
text = pytesseract.image_to_string(bw, config=tess_cfg).strip()
|
||||||
|
|
||||||
# retry without thresholding
|
try:
|
||||||
text2 = pytesseract.image_to_string(gray, config=cfg).strip()
|
# Schnellpfad: der Schwellwert-Durchlauf reicht meist -> zweiten (teuren)
|
||||||
|
# OCR-Lauf einsparen.
|
||||||
dt_local_naive = _parse_ocr_datetime(text, text2)
|
dt_local_naive = _parse_ocr_datetime(text)
|
||||||
|
except ValueError:
|
||||||
|
# Fallback: ohne Thresholding erneut lesen und beide Durchlaeufe parsen
|
||||||
|
# (identisches Verhalten wie zuvor).
|
||||||
|
text2 = pytesseract.image_to_string(gray, config=tess_cfg).strip()
|
||||||
|
dt_local_naive = _parse_ocr_datetime(text, text2)
|
||||||
|
|
||||||
# Keep camera-local timezone (no UTC conversion requested)
|
# Keep camera-local timezone (no UTC conversion requested)
|
||||||
return dt_local_naive.replace(tzinfo=customer_tzinfo)
|
return dt_local_naive.replace(tzinfo=customer_tzinfo)
|
||||||
@@ -383,28 +411,37 @@ def db_init(cur):
|
|||||||
updated_ts timestamptz NOT NULL DEFAULT now()
|
updated_ts timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
cur.execute("""
|
for col, typedef in [
|
||||||
ALTER TABLE remote_cam.import_job
|
("needs_ocr_backfill", "boolean NOT NULL DEFAULT false"),
|
||||||
ADD COLUMN IF NOT EXISTS needs_ocr_backfill boolean NOT NULL DEFAULT false;
|
("needs_exif_backfill", "boolean NOT NULL DEFAULT false"),
|
||||||
""")
|
]:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
ALTER TABLE remote_cam.import_job
|
SELECT 1 FROM information_schema.columns
|
||||||
ADD COLUMN IF NOT EXISTS needs_exif_backfill boolean NOT NULL DEFAULT false;
|
WHERE table_schema='remote_cam' AND table_name='import_job' AND column_name=%s
|
||||||
""")
|
""", (col,))
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS import_job_needs_ocr_idx ON remote_cam.import_job(needs_ocr_backfill);")
|
if not cur.fetchone():
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS import_job_needs_exif_idx ON remote_cam.import_job(needs_exif_backfill);")
|
cur.execute(f"ALTER TABLE remote_cam.import_job ADD COLUMN {col} {typedef};")
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS import_job_status_idx ON remote_cam.import_job(status);")
|
for idx, col in [
|
||||||
cur.execute("CREATE INDEX IF NOT EXISTS import_job_updated_idx ON remote_cam.import_job(updated_ts);")
|
("import_job_needs_ocr_idx", "needs_ocr_backfill"),
|
||||||
|
("import_job_needs_exif_idx", "needs_exif_backfill"),
|
||||||
|
("import_job_status_idx", "status"),
|
||||||
|
("import_job_updated_idx", "updated_ts"),
|
||||||
|
]:
|
||||||
|
cur.execute("SELECT 1 FROM pg_indexes WHERE schemaname='remote_cam' AND indexname=%s", (idx,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
cur.execute(f"CREATE INDEX {idx} ON remote_cam.import_job({col});")
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("SELECT 1 FROM pg_proc JOIN pg_namespace ON pg_proc.pronamespace=pg_namespace.oid WHERE nspname='remote_cam' AND proname='set_updated_ts'")
|
||||||
CREATE OR REPLACE FUNCTION remote_cam.set_updated_ts()
|
if not cur.fetchone():
|
||||||
RETURNS trigger AS $$
|
cur.execute("""
|
||||||
BEGIN
|
CREATE FUNCTION remote_cam.set_updated_ts()
|
||||||
NEW.updated_ts = now();
|
RETURNS trigger AS $$
|
||||||
RETURN NEW;
|
BEGIN
|
||||||
END;
|
NEW.updated_ts = now();
|
||||||
$$ LANGUAGE plpgsql;
|
RETURN NEW;
|
||||||
""")
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""")
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|||||||
Reference in New Issue
Block a user