Harden pre-production deployment with external environment and preflights

This commit is contained in:
2026-09-16 00:22:19 +02:00
parent 5d3ae10036
commit 2813795c9d
17 changed files with 1002 additions and 76 deletions
+2 -1
View File
@@ -16,7 +16,8 @@ GITEA_OWNER=kai
GITEA_REPO=pingu-concerts GITEA_REPO=pingu-concerts
# Server push defaults to off. Each environment needs its own service account and key. # Server push defaults to off. Each environment needs its own service account and key.
# Pre-Production: compose.preprod.yml enables push and requires FIREBASE_PROJECT_ID. # For Pre-Production use config/preprod.env.example OUTSIDE the checkout instead.
# scripts/deploy-preprod.sh uses /home/kai/.config/metalcircle/preprod.env explicitly.
# See docs/wiki/Firebase.md, section "Pre-Production Deployment". # See docs/wiki/Firebase.md, section "Pre-Production Deployment".
PUSH_ENABLED=false PUSH_ENABLED=false
FIREBASE_PROJECT_ID= FIREBASE_PROJECT_ID=
+5
View File
@@ -12,6 +12,11 @@ env/
.env .env
.env.* .env.*
!.env.example !.env.example
*.env
*.env.*
!*.env.example
*.swp
*.swo
compose.dev.yml compose.dev.yml
# Logs # Logs
+4
View File
@@ -23,6 +23,8 @@ db/init/ Initialschema für eine neue PostgreSQL-Datenbank
db/migrations/ Nachträgliche, reproduzierbare Schemaänderungen db/migrations/ Nachträgliche, reproduzierbare Schemaänderungen
android/ Capacitor-Projekt und Android-App android/ Capacitor-Projekt und Android-App
compose*.yml Containerdefinitionen und optionale Push-/Pre-Production-Konfiguration compose*.yml Containerdefinitionen und optionale Push-/Pre-Production-Konfiguration
config/ Vorlagen ohne Secrets
scripts/ Deployment und lokale Prüfungen
img/ Projektgrafiken img/ Projektgrafiken
``` ```
@@ -43,6 +45,8 @@ Das Schema wird beim Start aus `db/init/01_initial.sql` angelegt und von den Mig
Die erwarteten Variablen stehen in `.env.example`: PostgreSQL-Zugang, Initial-Admin, `COOKIE_SECURE` sowie die optionalen Gitea-Werte `GITEA_URL`, `GITEA_TOKEN`, `GITEA_OWNER` und `GITEA_REPO`. `.env`, Firebase-`google-services.json`, private Schlüssel, Datenbank-Dumps und lokale Uploads gehören nicht in Git. Es dürfen ausschließlich lokale Testwerte verwendet werden. Die erwarteten Variablen stehen in `.env.example`: PostgreSQL-Zugang, Initial-Admin, `COOKIE_SECURE` sowie die optionalen Gitea-Werte `GITEA_URL`, `GITEA_TOKEN`, `GITEA_OWNER` und `GITEA_REPO`. `.env`, Firebase-`google-services.json`, private Schlüssel, Datenbank-Dumps und lokale Uploads gehören nicht in Git. Es dürfen ausschließlich lokale Testwerte verwendet werden.
Pre-Production nutzt `./scripts/deploy-preprod.sh` und die externe Datei `/home/kai/.config/metalcircle/preprod.env`. Vorlage: [`config/preprod.env.example`](config/preprod.env.example). Firebase und Gitea müssen vor dem Containerwechsel den Preflight bestehen. Migration, Dateirechte und automatische Env-Backups stehen unter [Deployment](docs/wiki/Deployment.md).
## Android ## Android
```bash ```bash
+4
View File
@@ -1,5 +1,9 @@
.env .env
.env.* .env.*
*.env
*.env.*
*.swp
*.swo
secrets/ secrets/
**/*service-account*.json **/*service-account*.json
**/*service_account*.json **/*service_account*.json
+62
View File
@@ -0,0 +1,62 @@
"""Read-only bug-report connectivity check. Never emits response bodies or tokens."""
import re
from urllib.parse import quote, urlsplit
import httpx
from gitea_service import GiteaConfig, GiteaError
def check(config=None, transport=None):
config = config or GiteaConfig.from_env()
config.validate()
try:
parsed = urlsplit(config.url)
if (not parsed.hostname or re.search(r'\s|\\', config.url) or
parsed.port == 0 or config.owner in {'.', '..'} or config.repo in {'.', '..'}):
raise GiteaError('configuration')
except ValueError:
raise GiteaError('configuration') from None
try:
with httpx.Client(base_url=config.url.rstrip('/') + '/api/v1/',
headers={'Authorization': 'token ' + config.token,
'Accept': 'application/json'},
timeout=httpx.Timeout(15.0, connect=5.0),
follow_redirects=False, trust_env=False, transport=transport) as client:
identity = client.get('user')
identity.raise_for_status()
user = identity.json()
if not isinstance(user, dict) or not isinstance(user.get('login'), str):
raise GiteaError('invalid_response')
if user['login'] != 'metalcircle-bot':
raise GiteaError('wrong_account')
# Test the issue resource using the existing write:issue scope.
# GET repository metadata would require an additional read:repository scope.
repository = 'repos/' + quote(config.owner, safe='') + '/' + quote(config.repo, safe='')
response = client.get(repository + '/issues', params={'limit': 1, 'page': 1})
response.raise_for_status()
if not isinstance(response.json(), list):
raise GiteaError('invalid_response')
except GiteaError:
raise
except httpx.HTTPStatusError as error:
raise GiteaError('http_' + str(error.response.status_code)) from None
except (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError):
raise GiteaError('network_or_timeout') from None
except (ValueError, TypeError, KeyError, httpx.HTTPError):
raise GiteaError('invalid_response') from None
def main():
try:
check()
except GiteaError as error:
print('FAIL: gitea_' + error.kind)
return 1
print('PASS: Gitea reachable; metalcircle-bot verified; repository issues readable. '
'Read-only check; issue creation and attachments not tested.')
return 0
if __name__ == '__main__':
raise SystemExit(main())
+77
View File
@@ -0,0 +1,77 @@
from contextlib import redirect_stdout
from dataclasses import replace
from io import StringIO
import unittest
from unittest.mock import patch
import httpx
from gitea_service import GiteaConfig, GiteaError
import gitea_preflight
class GiteaPreflightTests(unittest.TestCase):
config = GiteaConfig('https://gitea.invalid', 'synthetic-private-value', 'kai', 'pingu-concerts')
def test_only_get_requests_with_existing_scopes_and_no_payload_logging(self):
requests = []
def handler(request):
requests.append(request)
self.assertEqual(request.method, 'GET')
self.assertNotIn(self.config.token, str(request.url))
self.assertEqual(request.headers['Authorization'], 'token ' + self.config.token)
if request.url.path.endswith('/user'):
return httpx.Response(200, json={'login': 'metalcircle-bot'})
self.assertEqual(request.url.path, '/api/v1/repos/kai/pingu-concerts/issues')
self.assertEqual(request.url.params['limit'], '1')
return httpx.Response(200, json=[{'body': 'private report contents'}])
output = StringIO()
with redirect_stdout(output):
gitea_preflight.check(self.config, httpx.MockTransport(handler))
self.assertEqual(output.getvalue(), '')
self.assertEqual(len(requests), 2)
def test_missing_gitea_values_and_invalid_configuration_never_contact_network(self):
for field in ('url', 'token', 'owner', 'repo'):
with self.subTest(field=field), self.assertRaisesRegex(GiteaError, '^configuration$'):
gitea_preflight.check(replace(self.config, **{field: ''}))
for field, value in (('url', 'file:///tmp/test'), ('url', 'https://user:pass@gitea.invalid'),
('url', 'https://gitea.invalid:invalid'), ('url', 'https://bad host'),
('url', 'https://gitea.invalid?token=secret'), ('owner', '../owner'),
('repo', '..'), ('repo', 'some/repo')):
with self.subTest(field=field, value=value), self.assertRaisesRegex(GiteaError, '^configuration$'):
gitea_preflight.check(replace(self.config, **{field: value}))
def test_wrong_account_stops_before_repository_access(self):
def handler(request):
self.assertTrue(request.url.path.endswith('/user'))
return httpx.Response(200, json={'login': 'kai'})
with self.assertRaisesRegex(GiteaError, '^wrong_account$'):
gitea_preflight.check(self.config, httpx.MockTransport(handler))
def test_http_failures_are_sanitized_for_both_requests(self):
for endpoint in ('/user', '/issues'):
for status in (401, 403, 404, 500, 503, 302):
def handler(request):
if request.url.path.endswith(endpoint):
return httpx.Response(status, text=self.config.token + ' private response')
return httpx.Response(200, json={'login': 'metalcircle-bot'})
with self.subTest(endpoint=endpoint, status=status):
with self.assertRaisesRegex(GiteaError, '^http_' + str(status) + '$'):
gitea_preflight.check(self.config, httpx.MockTransport(handler))
def test_timeout_network_and_invalid_response_are_sanitized(self):
for error in (httpx.ConnectError(self.config.token), httpx.ReadTimeout(self.config.token)):
def handler(request):
raise error
with self.assertRaisesRegex(GiteaError, '^network_or_timeout$'):
gitea_preflight.check(self.config, httpx.MockTransport(handler))
for response in (httpx.Response(200, text='invalid json'), httpx.Response(200, json=[])):
with self.assertRaisesRegex(GiteaError, '^invalid_response$'):
gitea_preflight.check(self.config, httpx.MockTransport(lambda request: response))
def test_cli_output_only_contains_safe_categories(self):
output = StringIO()
with patch.object(gitea_preflight, 'check', side_effect=GiteaError('http_403')), redirect_stdout(output):
self.assertEqual(gitea_preflight.main(), 1)
self.assertEqual(output.getvalue(), 'FAIL: gitea_http_403\n')
+5
View File
@@ -73,3 +73,8 @@ class PushPreflightTests(unittest.TestCase):
with self.subTest(target=target), patch(target, side_effect=ValueError('sensitive SDK details')): with self.subTest(target=target), patch(target, side_effect=ValueError('sensitive SDK details')):
with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_missing_unreadable_or_invalid$'): with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_missing_unreadable_or_invalid$'):
push_preflight.check(self.account) push_preflight.check(self.account)
def test_missing_credential_fails_without_disclosing_path_or_sdk_details(self):
with patch('push_preflight.Path.stat', side_effect=FileNotFoundError('private filename')):
with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_missing_unreadable_or_invalid$'):
push_preflight.check(self.account)
+8
View File
@@ -6,6 +6,14 @@ services:
file: compose.push.yml file: compose.push.yml
service: web service: web
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:?required variable POSTGRES_USER is missing}:${POSTGRES_PASSWORD:?required variable POSTGRES_PASSWORD is missing}@db:5432/${POSTGRES_DB:?required variable POSTGRES_DB is missing}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:?required variable INITIAL_ADMIN_USERNAME is missing}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:?required variable INITIAL_ADMIN_PASSWORD is missing}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:?required variable INITIAL_ADMIN_EMAIL is missing}
GITEA_URL: ${GITEA_URL:?required variable GITEA_URL is missing}
GITEA_TOKEN: ${GITEA_TOKEN:?required variable GITEA_TOKEN is missing}
GITEA_OWNER: ${GITEA_OWNER:?required variable GITEA_OWNER is missing}
GITEA_REPO: ${GITEA_REPO:?required variable GITEA_REPO is missing}
PUSH_ENABLED: "true" PUSH_ENABLED: "true"
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:?Set the Pre-Production Firebase project ID} FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:?Set the Pre-Production Firebase project ID}
COOKIE_SECURE: "true" COOKIE_SECURE: "true"
+29
View File
@@ -0,0 +1,29 @@
# Template only. Copy outside the checkout to:
# /home/kai/.config/metalcircle/preprod.env (owner kai, mode 600).
# Keep existing database/admin values when migrating. Never source this file.
# Compose dotenv syntax applies; single-quote passwords/tokens containing $ or #.
# Empty required values deliberately block deployment. No COMPOSE_* / DOCKER_* overrides.
POSTGRES_DB=concerts
POSTGRES_USER=concerts
POSTGRES_PASSWORD=
INITIAL_ADMIN_USERNAME=
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_EMAIL=
COOKIE_SECURE=true
# Dedicated metalcircle-bot token: read:user + write:issue, restricted to this repo.
GITEA_URL=http://192.168.178.5:3000
GITEA_TOKEN=
GITEA_OWNER=kai
GITEA_REPO=pingu-concerts
PUSH_ENABLED=true
FIREBASE_PROJECT_ID=metalcircle-30d9b
FIREBASE_SERVICE_ACCOUNT_FILE=/home/kai/.secrets/metalcircle/firebase-push-preprod.json
# GOOGLE_APPLICATION_CREDENTIALS is set by Compose inside the container.
# Optional, inclusive Europe/Berlin registration dates.
ALPHA_TESTER_UNTIL=2026-10-31
BETA_TESTER_UNTIL=2026-12-31
+5 -3
View File
@@ -5,9 +5,9 @@
| `POSTGRES_DB` | Name der PostgreSQL-Datenbank | erforderlich | | `POSTGRES_DB` | Name der PostgreSQL-Datenbank | erforderlich |
| `POSTGRES_USER` | Datenbankbenutzer | erforderlich | | `POSTGRES_USER` | Datenbankbenutzer | erforderlich |
| `POSTGRES_PASSWORD` | Datenbankpasswort | erforderlich, geheim | | `POSTGRES_PASSWORD` | Datenbankpasswort | erforderlich, geheim |
| `INITIAL_ADMIN_USERNAME` | Erstes Admin-Konto | lokal erforderlich | | `INITIAL_ADMIN_USERNAME` | Erstes Admin-Konto | lokal und Pre-Production erforderlich |
| `INITIAL_ADMIN_PASSWORD` | Passwort des Erstadmins | lokal erforderlich, geheim | | `INITIAL_ADMIN_PASSWORD` | Passwort des Erstadmins | lokal und Pre-Production erforderlich, geheim |
| `INITIAL_ADMIN_EMAIL` | E-Mail des Erstadmins | lokal erforderlich | | `INITIAL_ADMIN_EMAIL` | E-Mail des Erstadmins | lokal und Pre-Production erforderlich |
| `COOKIE_SECURE` | Secure-Flag der Session-Cookies | Produktion `true` | | `COOKIE_SECURE` | Secure-Flag der Session-Cookies | Produktion `true` |
| `GITEA_URL` | interne Gitea-Basisadresse | für Bugreporter erforderlich | | `GITEA_URL` | interne Gitea-Basisadresse | für Bugreporter erforderlich |
| `GITEA_TOKEN` | Token des `metalcircle-bot` | erforderlich, geheim | | `GITEA_TOKEN` | Token des `metalcircle-bot` | erforderlich, geheim |
@@ -24,4 +24,6 @@
`.env.example` enthält nur Platzhalter. `.env` wird nie committed. `google-services.json` liegt ausschließlich lokal im Android-App-Modul und wird durch `.gitignore` ausgeschlossen. `.env.example` enthält nur Platzhalter. `.env` wird nie committed. `google-services.json` liegt ausschließlich lokal im Android-App-Modul und wird durch `.gitignore` ausgeschlossen.
Pre-Production verwendet ausschließlich `/home/kai/.config/metalcircle/preprod.env` über `--env-file`; die Checkout-`.env` ist dafür keine Konfigurationsquelle mehr. Vorlage: `config/preprod.env.example`. Alle PostgreSQL-/Initial-Admin-/Gitea-Werte sowie `COOKIE_SECURE`, `PUSH_ENABLED`, `FIREBASE_PROJECT_ID` und `FIREBASE_SERVICE_ACCOUNT_FILE` sind dort verpflichtend. Env und Firebase-Key: Eigentümer Deployment-Benutzer, Modus 600 (oder 400), außerhalb des Checkouts. Die automatische Env-Sicherung hält zehn Kopien in einem privaten externen Verzeichnis. Details und Migration: [Deployment](Deployment.md).
`compose.preprod.yml` verwendet dieselben Push-Variablen und denselben Credential-Mount, setzt `PUSH_ENABLED=true` sowie `COOKIE_SECURE=true` und verlangt eine Projekt-ID. Der Host-Pfad muss zum eigenen Pre-Production-Key zeigen. Siehe [Pre-Production Deployment](Firebase.md#pre-production-deployment), einschließlich Offline-Prüfung gegen den erwarteten Service Account und Abschaltung ohne das aktivierende Override. Die Android-Variablen sind Build-/Sync-Einstellungen und werden nicht aus der Backend-`.env` an die APK übertragen. `compose.preprod.yml` verwendet dieselben Push-Variablen und denselben Credential-Mount, setzt `PUSH_ENABLED=true` sowie `COOKIE_SECURE=true` und verlangt eine Projekt-ID. Der Host-Pfad muss zum eigenen Pre-Production-Key zeigen. Siehe [Pre-Production Deployment](Firebase.md#pre-production-deployment), einschließlich Offline-Prüfung gegen den erwarteten Service Account und Abschaltung ohne das aktivierende Override. Die Android-Variablen sind Build-/Sync-Einstellungen und werden nicht aus der Backend-`.env` an die APK übertragen.
+170 -8
View File
@@ -1,15 +1,177 @@
# Deployment # Deployment
Es gibt drei getrennte Umgebungen: MetalCircle hat getrennte Umgebungen: lokale Entwicklung auf PinguCore, Pre-Production unter **https://konzerte.pinguholic.de/** und spätere Produktion. Codex bereitet Änderungen lokal vor; Serverzugriff und Deployment erfolgen durch den Betreiber. Keine lokalen Datenbanken oder Credentials auf Pre-Production übernehmen.
1. Lokale Entwicklung auf PinguCore/Codex mit Docker Compose und Testdaten. ## Pre-Production: verbindliche Pfade
2. Aktuelle Pre-Production / Cloud-Staging unter **https://konzerte.pinguholic.de/** für gemeinsame Integrationstests.
3. Produktion.
Lokale Änderungen werden in Git geprüft und gepusht. Der Cloud-Testserver zieht den Stand anschließend eigenständig; Codex soll ihn nicht automatisch anmelden, verändern oder deployen. Produktion wird durch diese Dokumentation nicht verändert. Zugangsdaten und konkrete produktive Adressen gehören nicht ins Repository. | Zweck | Pfad |
|---|---|
| Git-Checkout | `/opt/pingu-concerts` |
| Aktive Environment-Datei | `/home/kai/.config/metalcircle/preprod.env` |
| Lokale Env-Sicherungen | `/home/kai/.config/metalcircle/backups/` |
| Firebase-Key auf dem Host | `/home/kai/.secrets/metalcircle/firebase-push-preprod.json` |
| Read-only Mount im Webcontainer | `/run/secrets/firebase-service-account.json` |
## Pre-Production Push Der Checkout benötigt keine aktive `.env` mehr. `scripts/deploy-preprod.sh` definiert den Environment-Pfad zentral und führt über den stdlib-Helfer `scripts/preprod_config.py` diese Compose-Kombination aus:
Die vorbereitete Kombination `compose.yml` + `compose.preprod.yml` übernimmt den read-only Firebase-Mount aus `compose.push.yml`, aktiviert Push und erzwingt HTTPS-Cookies. Ein eigener Pre-Production-Service-Account ist erforderlich. Basis-/Local-Konfiguration wird damit nicht automatisch umgestellt. Die HTTPS-Domain wurde vom Betreiber bestätigt. Die Befehle werden im bestehenden Server-Checkout von `kai/pingu-concerts` ausgeführt; dessen absoluter Pfad und gegebenenfalls zusätzliche serverseitige Overrides sind im Repository nicht hinterlegt. ```bash
sudo docker compose \
--env-file /home/kai/.config/metalcircle/preprod.env \
-f compose.yml -f compose.preprod.yml <Unterbefehl>
```
Der vollständige Ablauf mit IAM, Secret-Rechten, Offline-Preflight, Android-Ziel-URL, Aktivierung/Abschaltung und drei Gerätetests steht unter [Firebase → Pre-Production Deployment](Firebase.md#pre-production-deployment). Bestehenden Compose-Projektnamen und Daten-Volumes erhalten; keine Local-Datenbank auf den Server kopieren. Voraussetzungen: Bash, Python 3.10+, Git, Docker Compose v2 mit `config --environment` und sudo-Berechtigung für lokales Docker. Als Deployment-Benutzer **kai**, nicht das gesamte Script mit sudo starten. Auf dem Host sind keine zusätzlichen Python-Pakete nötig. Compose-Projekt `pingu-concerts` und vorhandene Daten-/Upload-Volumes beibehalten.
## Konfiguration und Rechte
[`config/preprod.env.example`](../../config/preprod.env.example) ist die Vorlage ohne Secrets. Pflichtvariablen:
- `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`
- `INITIAL_ADMIN_USERNAME`, `INITIAL_ADMIN_PASSWORD`, `INITIAL_ADMIN_EMAIL`
- `GITEA_URL`, `GITEA_TOKEN`, `GITEA_OWNER`, `GITEA_REPO`
- `PUSH_ENABLED`, `FIREBASE_PROJECT_ID`, `FIREBASE_SERVICE_ACCOUNT_FILE`, `COOKIE_SECURE`
Die Bootstrap-Adminwerte bleiben auch bei bestehender Datenbank Teil des vollständigen Deployment-Vertrags. DB-/Adminwerte aus der bestehenden Installation übernehmen: Ein geändertes `POSTGRES_PASSWORD` in der Datei ändert **nicht** das Passwort der bereits initialisierten PostgreSQL-Rolle.
Für normale Pre-Production-Deployments müssen `PUSH_ENABLED=true`, `COOKIE_SECURE=true` und `FIREBASE_PROJECT_ID=metalcircle-30d9b` gesetzt sein. `GOOGLE_APPLICATION_CREDENTIALS` setzt Compose intern. `ALPHA_TESTER_UNTIL` und `BETA_TESTER_UNTIL` bleiben optional.
Env-Datei und Firebase-Key müssen reguläre Dateien außerhalb des Checkouts sein, dem Deployment-Benutzer gehören und Modus **600** (alternativ 400) besitzen. Symlinks und Gruppen-/Weltzugriff werden abgelehnt. Verzeichnisse unter `.config/metalcircle` und `.secrets/metalcircle` mit **700** anlegen. Keine Keys in Buildkontext oder Image ablegen.
Compose interpretiert die dotenv-Syntax selbst. Einzeilige Passwörter/Tokens mit `$` oder `#` in einfache Anführungszeichen setzen; die Datei niemals mit `source` ausführen. Der Helfer entfernt geerbte Shell-Variablen, die sonst laut [Compose-Priorität](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) die Env-Datei übersteuern könnten. Fehlende Variablen werden weder durch die alte Checkout-`.env` noch durch Shell-Exports ergänzt. Keine `COMPOSE_*`-/`DOCKER_*`-Overrides in `preprod.env` eintragen; ein abweichendes Compose-Projekt wird abgelehnt.
## Migration der bisherigen Checkout-.env
Diese Schritte führt der Betreiber einmalig auf Pre-Production aus. Das Script löscht oder verändert `/opt/pingu-concerts/.env` nicht.
1. Nach Bereitstellung der Änderung den Checkout aktualisieren. Bestehende Konfiguration geschützt aufbewahren und auf Vollständigkeit prüfen.
2. Geschützte Verzeichnisse anlegen:
```bash
install -d -m 700 /home/kai/.config/metalcircle /home/kai/.secrets/metalcircle
```
3. Eine bestehende Zieldatei nicht überschreiben. Die bisherige `.env` kann als Ausgangspunkt dienen:
```bash
test ! -e /home/kai/.config/metalcircle/preprod.env && \
install -m 600 /opt/pingu-concerts/.env /home/kai/.config/metalcircle/preprod.env
```
Alternativ bei fehlender/ungeeigneter Altdatei die Vorlage verwenden, ebenfalls nur bei noch nicht vorhandenem Ziel:
```bash
test ! -e /home/kai/.config/metalcircle/preprod.env && \
install -m 600 /opt/pingu-concerts/config/preprod.env.example /home/kai/.config/metalcircle/preprod.env
```
Die aktiven DB-/Adminwerte und den dedizierten `metalcircle-bot`-Token im Editor ergänzen. Alle Variablennamen mit der Vorlage abgleichen. Keine Secret-Inhalte in Terminalausgabe, Tickets oder Chats kopieren.
4. Den eigenen **Pre-Production-Key** am oben angegebenen Host-Pfad bereitstellen, Eigentümer kai und Modus 600 prüfen. `FIREBASE_SERVICE_ACCOUNT_FILE` muss diesen absoluten Pfad enthalten. Local-Key nicht wiederverwenden. [Firebase-Einrichtung und IAM](Firebase.md#pre-production-deployment).
5. Nur die Prüfungen ausführen; dies baut das Web-Image, ersetzt aber keinen laufenden Container:
```bash
cd /opt/pingu-concerts
./scripts/deploy-preprod.sh --check
```
6. Bei PASS normal deployen, danach Login, einen Bugreport mit optionalem Screenshot und die drei Push-Typen prüfen:
```bash
./scripts/deploy-preprod.sh
```
7. Erst nach erfolgreichem Anwendungstest die alte `/opt/pingu-concerts/.env` **manuell** geschützt außerhalb des Checkouts archivieren oder entfernen. Editor-Swap-/Backupdateien können ebenfalls Secrets enthalten. Der neue Ablauf ist bereits vor diesem Aufräumen unabhängig von der Altdatei.
## Normaler Ablauf und Abbruchbedingungen
`./scripts/deploy-preprod.sh`:
1. Verlangt Repository-Kontext, Branch `main` und sauberen Working Tree; führt `git pull --ff-only origin main` aus.
2. Prüft Env-Datei, Eigentümer, Rechte und Pflichtwerte sowie den externen Firebase-Key. Ausgabe enthält nur Variablennamen und `set` bzw. den fehlenden Namen. Compose-Parsing erfolgt intern, ohne Werte oder rohe Fehlermeldungen auszugeben.
3. Führt `config --quiet` aus und baut ausschließlich `web`.
4. Startet zwei kurzlebige Container mit `run --rm --no-deps -T web`: zuerst `push_preflight.py`, dann `gitea_preflight.py`. Beide müssen Exit-Code 0 **und** `PASS` liefern. Diese Programme starten weder FastAPI noch den Push-Worker und führen keine Migration aus.
5. Prüft, ob Env-Datei oder Key seit Beginn der Prüfung verändert wurden, und bricht gegebenenfalls ab. Während eines Deployments diese Dateien nicht bearbeiten.
6. Sichert die geprüfte Env-Datei. Erst dann folgt `up -d --no-deps web`.
7. Wiederholt beide Preflights per `exec -T web` im laufenden Container und zeigt Containerstatus, gefilterte Web-Logs (letzte zwei Minuten, höchstens 100 Eingabezeilen) und den deployten Commit.
Beispiel vor dem Containerwechsel:
```text
ERROR: required variable GITEA_TOKEN is missing
Deployment aborted. Running container unchanged.
```
Fehler bei Konfiguration, Build, Vorprüfungen oder Backup ersetzen keinen laufenden Container. Scheitert eine Prüfung **nach** `up`, meldet das Script ausdrücklich den bereits erfolgten Update-Versuch. Kein automatischer Rollback, kein `down`, kein Volume-Löschen, kein DB-Recreate und kein `--force-recreate`. Beim regulären FastAPI-Start läuft weiterhin die bestehende Schema-Initialisierung; diese Änderung führt keine neue Migration ein.
`--check` überspringt Pull, Env-Backup und Containerwechsel. Der vorhandene Checkout wird geprüft und das Image gebaut. Die lokale Vorbereitung ersetzt keine Pre-Production-Smoke-Tests.
## Was die Container-Preflights prüfen
**Firebase:** Der bestehende Offline-Preflight prüft Aktivierung, exakten Containerpfad, reguläre Datei, restriktive Rechte, read-only Mount, lesbares Credential, Projekt und exakt `metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com`. Falsche Local-Credentials werden abgelehnt. IAM, OAuth und Zustellung werden dabei nicht über das Netzwerk getestet. Die erforderliche Rolle bleibt **`roles/firebasecloudmessaging.admin`**.
**Gitea:** `gitea_preflight.py` validiert die vier vorhandenen Variablen, URL und Repository-Namen. Danach ausschließlich:
- `GET /api/v1/user`: verlangt `login == "metalcircle-bot"`.
- `GET /api/v1/repos/{owner}/{repo}/issues?limit=1&page=1`: prüft Erreichbarkeit des Repository-Issue-Bereichs mit den bereits benötigten Token-Scopes `read:user` und `write:issue`. Kein zusätzliches `read:repository` nötig. Siehe [Gitea-API-Zugang](https://docs.gitea.com/1.26/development/api-usage).
Es werden weder Issues noch Anhänge angelegt. Response-Inhalte und Tokens werden nicht geloggt. Ein read-only PASS bestätigt keinen Schreib-/Attachment-Vorgang; ein vom Betreiber ausgelöster Bugreport gehört deshalb zum Smoke-Test. Timeouts, Authentifizierungsfehler, fehlende Rechte und ungültige Antworten stoppen das Deployment mit festen Fehlerkategorien.
## Env-Backups
Nach bestandenen Vorprüfungen entsteht unter `/home/kai/.config/metalcircle/backups/` eine Kopie `preprod.env.YYYYMMDD-HHMMSS-microseconds` (UTC). Verzeichnis **700**, Dateien **600**, nur die letzten **10** Sicherungen dieses Namensschemas werden behalten. Fremde Dateien werden nicht gelöscht. Gesichert wird ausschließlich die Env-Datei, nicht der Firebase-Key oder die Datenbank.
Eine Sicherung bleibt auch bei einem später fehlgeschlagenen Container-Update erhalten. Fehler beim Sichern/Aufräumen brechen vor dem Update ab. Diese Dateien enthalten Secrets und gehören niemals ins Git. Bei Wiederherstellung im Editor kontrollieren, geschützt nach `preprod.env` kopieren, `--check` ausführen und normal deployen. Die Sicherungen sind keine unabhängige Offsite-Backup-Strategie.
## Manueller Fallback / Troubleshooting
Die einzelnen Compose-Schritte bleiben verfügbar. Diese Hilfsfunktion verwendet dieselbe explizite Env-Datei, bereinigte Shell-Umgebung und sichere Ausgabe wie das Script. Intern wird jeweils der oben gezeigte `sudo docker compose --env-file ... -f compose.yml -f compose.preprod.yml`-Befehl ausgeführt:
```bash
cd /opt/pingu-concerts
git status --short
# Nur main und einen sauberen Checkout verwenden.
git pull --ff-only origin main
compose_preprod() {
python3 scripts/preprod_config.py \
--env-file /home/kai/.config/metalcircle/preprod.env compose "$@"
}
python3 scripts/preprod_config.py --env-file /home/kai/.config/metalcircle/preprod.env check
compose_preprod config --quiet
compose_preprod build web
compose_preprod run --rm --no-deps -T web python push_preflight.py \
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com
compose_preprod run --rm --no-deps -T web python gitea_preflight.py
# Nur nach ALLEN erfolgreichen Prüfungen weiter; bei jedem Fehler abbrechen.
python3 scripts/preprod_config.py --env-file /home/kai/.config/metalcircle/preprod.env backup
compose_preprod up -d --no-deps web
compose_preprod exec -T web python push_preflight.py \
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com
compose_preprod exec -T web python gitea_preflight.py
compose_preprod ps --format json
compose_preprod logs --since=2m --tail=100 --no-color web
git log -1 --format='%h %s'
```
Keine vollständigen `compose config`, `config --environment`, `docker inspect` oder `.env`-Inhalte ausgeben/teilen. Der Helfer erfasst die Compose-Interpolation intern im Speicher; stdout/stderr mit potentiellen Secrets werden nicht durchgereicht. Web-Logs zeigen nur ausgewählte Lebenszyklusmeldungen, HTTP-Methode/Status **ohne URL** und feste Gitea-/Push-Fehlerkategorien. Andere Zeilen werden gezählt und ausgelassen.
| Meldung | Prüfung durch Betreiber |
|---|---|
| `required variable ... is missing` | Benannte Variable in der externen Env-Datei ergänzen; alte `.env` hilft nicht |
| `requires permissions 600 or 400` | Dateirechte und Eigentümer prüfen |
| `gitea_configuration` | Basis-URL ohne Login/Query/Fragment, Owner und Repository prüfen |
| `gitea_wrong_account` | Dedizierten `metalcircle-bot`-Token verwenden |
| `gitea_http_401` | Token ungültig/widerrufen |
| `gitea_http_403` | Bot-Zugriff und `read:user`/`write:issue` prüfen |
| `gitea_http_404` | Repository/URL oder Sichtbarkeit für Bot prüfen |
| `gitea_http_5xx` / `gitea_network_or_timeout` | Gitea-Verfügbarkeit, WireGuard, DNS/Netz prüfen |
| `gitea_invalid_response` | Falsches Ziel/Proxy oder unerwartete API-Antwort prüfen |
| Firebase `credential_service_account_mismatch` | Pre-Production-Key statt Local-Key verwenden |
| `Compose build failed` | Build/Registry-Erreichbarkeit prüfen; laufender Container wurde nicht ersetzt |
## Lokale Verifikation
```bash
bash -n scripts/deploy-preprod.sh
python3 -m unittest discover -s scripts/tests -v
```
Shell-Ablauftests benutzen temporäre Verzeichnisse und simulierte Git-/Docker-Kommandos, keine echten Deployments. Ein Test verwendet nur den lokalen Compose-Parser mit synthetischer Env-Datei, ohne Docker-Engine/Netz. Die Backend-Suite unter `app/tests` deckt Gitea- und Firebase-Preflights ab; [Testaufruf](Development-Setup.md#tests).
+17 -45
View File
@@ -46,7 +46,7 @@ Automatisierte Backend-Tests simulieren Firebase und belegen nicht die Cloud-IAM
### Bestand und Grenzen ### Bestand und Grenzen
Im Repository gibt es `compose.yml` für Web/DB, die optionale Secret-Einbindung `compose.push.yml` und eine lokale, ignorierte `compose.dev.yml` mit Reload/Quellcode-Mount. Die vom Betreiber bestätigte aktuelle Pre-Production-Adresse ist **https://konzerte.pinguholic.de/**; das Repository bleibt **`kai/pingu-concerts`**. Der absolute Server-Checkout-Pfad und zusätzliche Reverse-Proxy-/Compose-Overrides sind nicht dokumentiert. Die folgenden Befehle werden **vom Betreiber im bestehenden Pre-Production-Checkout** ausgeführt. Den vorhandenen Compose-Projektnamen und gegebenenfalls serverseitige Overrides beibehalten, damit dieselbe Pre-Production-Datenbank und dieselben Upload-Volumes verwendet werden. Keine zweite Installation mit denselben festen Containernamen auf demselben Docker-Host starten. Im Repository gibt es `compose.yml` für Web/DB, die optionale Secret-Einbindung `compose.push.yml` und eine lokale, ignorierte `compose.dev.yml` mit Reload/Quellcode-Mount. Die vom Betreiber bestätigte aktuelle Pre-Production-Adresse ist **https://konzerte.pinguholic.de/**; das Repository bleibt **`kai/pingu-concerts`**. Der Server-Checkout liegt unter `/opt/pingu-concerts`; die aktive Konfiguration liegt außerhalb unter `/home/kai/.config/metalcircle/preprod.env`. Die folgenden Befehle werden **vom Betreiber im bestehenden Pre-Production-Checkout** ausgeführt. Den vorhandenen Compose-Projektnamen und gegebenenfalls serverseitige Overrides beibehalten, damit dieselbe Pre-Production-Datenbank und dieselben Upload-Volumes verwendet werden. Keine zweite Installation mit denselben festen Containernamen auf demselben Docker-Host starten.
Neu ist `compose.preprod.yml`: Es übernimmt per `extends` die Einbindung aus `compose.push.yml`, aktiviert Push und erzwingt sichere Cookies. Start mit `compose.yml` + `compose.preprod.yml`; die dritte Datei muss nicht zusätzlich angegeben werden. Pre-Production benötigt HTTPS am vorhandenen Reverse Proxy. Die Basisdatei behält `PUSH_ENABLED=false` als Standard; lokale Konfiguration und spätere Produktion werden nicht automatisch aktiviert. Neu ist `compose.preprod.yml`: Es übernimmt per `extends` die Einbindung aus `compose.push.yml`, aktiviert Push und erzwingt sichere Cookies. Start mit `compose.yml` + `compose.preprod.yml`; die dritte Datei muss nicht zusätzlich angegeben werden. Pre-Production benötigt HTTPS am vorhandenen Reverse Proxy. Die Basisdatei behält `PUSH_ENABLED=false` als Standard; lokale Konfiguration und spätere Produktion werden nicht automatisch aktiviert.
@@ -61,66 +61,36 @@ Der lokale Account `metalcircle-push-local@metalcircle-30d9b.iam.gserviceaccount
### A. Einmalig auf dem Pre-Production-Host ### A. Einmalig auf dem Pre-Production-Host
Es ist kein bestehendes Secret-Verzeichnis dokumentiert. **Vorschlag**, falls der Betreiber noch keine Konvention hat: `$HOME/.secrets/metalcircle/firebase-push-preprod.json` im Home-Verzeichnis des Deployment-Benutzers, außerhalb von Checkout und Docker-Buildkontext. Verbindlicher Host-Pfad: `/home/kai/.secrets/metalcircle/firebase-push-preprod.json`. Der Key gehört dem Deployment-Benutzer kai und hat Modus 600, das Verzeichnis Modus 700. Der bestehende read-only Mount bleibt `/run/secrets/firebase-service-account.json`; `create_host_path: false` verhindert Verzeichnisse anstelle fehlender Dateien. Bei Rootless-Docker oder abweichender Container-UID muss der Betreiber die UID-Abbildung prüfen; nicht auf weltweite Leserechte ausweichen.
```bash Die gesamte aktive Konfiguration liegt in `/home/kai/.config/metalcircle/preprod.env` (Eigentümer kai, Modus 600). Vorlage: `config/preprod.env.example`. Neben vollständigen DB-/Admin-/Gitea-Werten benötigt Firebase:
install -d -m 700 "$HOME/.secrets/metalcircle"
# Hier ausschließlich den NEUEN Pre-Production-Schlüssel sicher ablegen.
chmod 600 "$HOME/.secrets/metalcircle/firebase-push-preprod.json"
```
Die Datei muss dem Deployment-Benutzer gehören. Falls die sichere Übertragung einen anderen Eigentümer gesetzt hat, korrigiert der Betreiber ihn, z. B. mit `sudo chown "$(id -u):$(id -g)" "$HOME/.secrets/metalcircle/firebase-push-preprod.json"`. Das bestehende Dockerfile läuft als Container-root; bei Rootless-Docker oder zusätzlichen `user:`-Overrides muss die UID-Abbildung berücksichtigt werden. Nicht auf `chmod 644` ausweichen.
In der **bestehenden, nicht versionierten Server-`.env`** nur diese Werte ergänzen; DB-/Admin-/Gitea-Konfiguration beibehalten:
```dotenv ```dotenv
PUSH_ENABLED=true PUSH_ENABLED=true
FIREBASE_PROJECT_ID=metalcircle-30d9b FIREBASE_PROJECT_ID=metalcircle-30d9b
FIREBASE_SERVICE_ACCOUNT_FILE=/absoluter/hostpfad/zur/firebase-push-preprod.json FIREBASE_SERVICE_ACCOUNT_FILE=/home/kai/.secrets/metalcircle/firebase-push-preprod.json
COOKIE_SECURE=true COOKIE_SECURE=true
``` ```
Den Beispielpfad durch den tatsächlichen absoluten Pfad ersetzen, kein `~` in `.env`. `GOOGLE_APPLICATION_CREDENTIALS` wird durch Compose auf `/run/secrets/firebase-service-account.json` gesetzt. Der Bind-Mount ist `read_only: true`; `create_host_path: false` verhindert, dass eine fehlende Schlüsseldatei unbemerkt als Verzeichnis angelegt wird. Ein Host-Pfad ist kein Schlüsselinhalt und darf dokumentiert werden; JSON-Inhalt, private Schlüssel, OAuth- und FCM-Tokens niemals. `GOOGLE_APPLICATION_CREDENTIALS` setzt Compose intern. Kein `~` als Pfad und keine Credential-Inhalte in diese Anleitung übernehmen. Die alte Checkout-`.env` wird vom Deployment nicht mehr gelesen und nicht automatisch gelöscht. Die vollständige [Migrationsanleitung](Deployment.md#migration-der-bisherigen-checkout-env) beschreibt Übernahme, Prüfung, Deployment und das spätere manuelle Archivieren.
### B. Deployment im bestehenden Pre-Production-Checkout ### B. Deployment im Pre-Production-Checkout
Vor Aktivierung bestätigen: Die vorhandene DB ist die Pre-Production-DB, keine lokalen Daten oder Sessions wurden importiert. Übernommene `sessions`, `push_devices` und `push_notifications` aus einem anderen Umfeld müssen vor Aktivierung gezielt bereinigt werden; dies nicht durch Kopieren lokaler Daten lösen. Für den Smoke-Test frisch in Pre-Production anmelden und das Gerät dort registrieren.
Der bevorzugte reguläre Deployment-Weg im bestehenden Checkout ist nun:
```bash ```bash
cd /opt/pingu-concerts
./scripts/deploy-preprod.sh --check
./scripts/deploy-preprod.sh ./scripts/deploy-preprod.sh
``` ```
Das Script verlangt Branch `main` und einen sauberen Working Tree, führt `git pull --ff-only origin main` aus und benutzt durchgehend `sudo docker compose -f compose.yml -f compose.preprod.yml`. Es prüft die Konfiguration still, baut nur das Web-Image und führt den erwarteten Service-Account-Preflight in einem temporären Container aus. Erst bei Exit-Code 0 und `PASS` aktualisiert es mit `up -d --no-deps web`; danach prüft es den laufenden Container. Zum Abschluss zeigt es Compose-Status, höchstens 100 Web-Logzeilen der letzten zwei Minuten und den deployten Commit. Build- oder Preflight-Fehler vor `up` lassen den laufenden Webcontainer unverändert. Das Script führt kein `down`, keine Volume-Operation, keinen erzwungenen Recreate und keine DB-Aktualisierung aus. Das Script verlangt einen sauberen `main`-Checkout und verwendet durchgehend `--env-file /home/kai/.config/metalcircle/preprod.env` mit `compose.yml` + `compose.preprod.yml`. Pflichtwerte, Host-Dateirechte und externer Firebase-Key werden vor dem Build geprüft. Vor dem Containerwechsel müssen anschließend **Firebase und Gitea** in temporären Containern PASS liefern. Danach wird die Env-Datei außerhalb des Checkouts gesichert (700/600, letzte zehn Kopien) und nur `web` mit `up -d --no-deps web` aktualisiert. Beide Preflights laufen danach erneut im tatsächlichen Webcontainer. [Ablauf, Abbruchverhalten und manueller Fallback](Deployment.md).
#### Manueller Fallback / Troubleshooting Der bestehende Firebase-Preflight bleibt offline: Push aktiv, Containerpfad, reguläre Datei, restriktive Rechte, read-only Mount, gültiges Credential, Projekt und exakter Pre-Production-Service-Account. PASS belegt **kein** IAM und keine FCM-Zustellung. Gitea wird zusätzlich read-only als `metalcircle-bot` auf Erreichbarkeit geprüft; keine Test-Issues oder Attachments werden erstellt.
Die einzelnen Schritte bleiben für Diagnose oder Script-Ausfall verfügbar. In allen Befehlen dieselben gegebenenfalls vorhandenen Server-Overrides ergänzen: Vor Aktivierung bestätigen: Die vorhandene DB ist die Pre-Production-DB, keine lokalen Daten/Sessions wurden importiert. Frisch in Pre-Production anmelden und das Android-Gerät dort registrieren. Keine Local-Datenbank kopieren.
```bash Eine bewusste **Notabschaltung** des Pushversands erfolgt außerhalb des normalen Deployment-Scripts: In der externen `preprod.env` `PUSH_ENABLED=false` setzen und den Webcontainer ausdrücklich mit `--env-file /home/kai/.config/metalcircle/preprod.env`, `compose.yml` + `compose.push.yml` aktualisieren. Das reguläre Pre-Production-Override erzwingt Push und ist für diese Ausnahme wegzulassen; `COOKIE_SECURE=true` in der externen Env beibehalten. Kein `down`, kein DB-Recreate. Für den nächsten normalen Deployment-Lauf Push wieder aktivieren und beide Preflights bestehen lassen.
git status --short
git pull --ff-only origin main
sudo docker compose -f compose.yml -f compose.preprod.yml config --quiet
sudo docker compose -f compose.yml -f compose.preprod.yml build web
sudo docker compose -f compose.yml -f compose.preprod.yml run --rm --no-deps web \
python push_preflight.py \
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com
# Nur bei PASS fortsetzen. Das bestehende db-Service muss bereits laufen.
sudo docker compose -f compose.yml -f compose.preprod.yml up -d --no-deps web
sudo docker compose -f compose.yml -f compose.preprod.yml exec -T web \
python push_preflight.py \
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com
sudo docker compose -f compose.yml -f compose.preprod.yml ps
sudo docker compose -f compose.yml -f compose.preprod.yml logs --since=2m --tail=100 --no-color web
git log -1 --format='%h %s'
```
Zusätzliche bisher verwendete Server-Overrides bei diesen Befehlen beibehalten und ihre endgültigen Werte kontrollieren. Keine neue DB anlegen, kein `down -v`, kein Entwicklungs-Reload-Mount. `config --quiet` prüft ohne Ausgabe der interpolierten Secrets; vollständige `compose config`-/`docker inspect`-Ausgaben nicht teilen. Der normale FastAPI-Start führt die vorhandene Schema-Prüfung aus; diese Push-Konfiguration benötigt keine neue Migration. Schlüsselrotation: neue Pre-Production-Datei geschützt am Host ersetzen und den Webcontainer über den normalen Deployment-Weg neu erstellen, damit Mount und SDK-Credential erneuert werden. Während eines laufenden Deployments Env/Key nicht bearbeiten.
`push_preflight.py` prüft nur lokal: Push aktiv, Pfad, reguläre Datei, restriktive Rechte, read-only Mount, syntaktisch lesbares Credential, Projekt und exakte Service-Account-Adresse. Damit fällt auch ein versehentlich eingesetzter Local-Key auf. Es gibt keine Netzwerkanfrage, keinen Push und keinen IAM-Nachweis. PASS ersetzt den echten Smoke-Test nicht.
Zum Deaktivieren in der Server-`.env` `PUSH_ENABLED=false` setzen und den Webcontainer **mit `compose.yml` + `compose.push.yml`** neu erstellen. Das Pre-Production-Override setzt explizit `true` und muss für diese Abschaltung entfallen. Der Mount kann bleiben. Bei erneutem Aktivieren können noch nicht abgelaufene Aufträge verarbeitet werden; während der Abschaltung entstehen keine neuen. Schlüsselrotation: sichere neue Datei am Host ersetzen und Webcontainer neu erstellen, damit Bind-Mount und gecachtes SDK-Credential erneuert werden.
### Android mit Pre-Production verbinden ### Android mit Pre-Production verbinden
@@ -150,7 +120,7 @@ Zwei **Pre-Production-Testkonten** A/B verwenden. B ist auf dem Android-Testger
Für die Backend-Prüfung können selektive SQL-Abfragen benutzt werden (IDs/Zeitpunkt eingrenzen; kein `SELECT *`): Für die Backend-Prüfung können selektive SQL-Abfragen benutzt werden (IDs/Zeitpunkt eingrenzen; kein `SELECT *`):
```bash ```bash
docker compose -f compose.yml -f compose.preprod.yml exec -T db \ sudo docker compose --env-file /home/kai/.config/metalcircle/preprod.env -f compose.yml -f compose.preprod.yml exec -T db \
sh -c 'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL' sh -c 'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT user_id, platform, app_version, created_at, last_seen_at FROM push_devices ORDER BY last_seen_at DESC LIMIT 10; SELECT user_id, platform, app_version, created_at, last_seen_at FROM push_devices ORDER BY last_seen_at DESC LIMIT 10;
SELECT user_id, language, friend_request, direct_message, event_invitation FROM notification_preferences ORDER BY user_id; SELECT user_id, language, friend_request, direct_message, event_invitation FROM notification_preferences ORDER BY user_id;
@@ -198,6 +168,8 @@ Alle drei Tests zusätzlich mit B in EN wiederholen; ursprüngliche Sprache/Prä
### Lokal geprüfte Pre-Production-Vorbereitung ### Lokal geprüfte Pre-Production-Vorbereitung
Die Repository-Vorbereitung wurde mit 64 erfolgreichen Backend-Tests (isolierte lokale PostgreSQL-Schemas, simuliertes Firebase) und 11 JavaScript-Tests für Push-Registrierung/-Navigation und Android-Zurück-Verhalten geprüft. Neue Prüfungen decken insbesondere falsche Service Accounts, Projekt-/Mount-/Dateirechte, relative authentifizierte Registrierungs-URLs, die gemeinsamen Payload-Eigenschaften aller drei Push-Arten und den Lesestatus von Direktnachrichten ab. Ein veralteter Sprach-Test wurde an das bereits bestehende DE/EN-Dropdown angepasst; keine Oberflächenänderung. Die ursprüngliche Push-Vorbereitung wurde mit 64 erfolgreichen Backend-Tests (isolierte lokale PostgreSQL-Schemas, simuliertes Firebase) und 11 JavaScript-Tests für Push-Registrierung/-Navigation und Android-Zurück-Verhalten geprüft. Diese Prüfungen decken insbesondere falsche Service Accounts, Projekt-/Mount-/Dateirechte, relative authentifizierte Registrierungs-URLs, die gemeinsamen Payload-Eigenschaften aller drei Push-Arten und den Lesestatus von Direktnachrichten ab.
Die anschließende Absicherung des Deployments mit externer Environment-Datei wurde mit inzwischen **84 Backend-Tests, 22 Host-/Deployment-Tests und 11 JavaScript-Tests** geprüft. Dazu gehören fehlende Gitea-Werte, Bot-Identität, API-Fehler, Rechte, fehlende Dateien, sichere Backup-Rotation und Abbruch vor dem Containerwechsel. Die Compose-Interpolation wurde mit synthetischen Werten auch gegen den echten lokalen Compose-Parser geprüft. Keine Pre-Production-Secrets und kein Serverdeployment waren dafür nötig.
Compose-Konfigurationen für Basis, Local-Push und Pre-Production wurden mit synthetischen Konfigurationswerten geprüft, einschließlich Abbruch bei fehlendem Projekt/Secret-Pfad. Das Backend-Image wurde lokal gebaut und auf Credential-Dateien/Schlüssel geprüft; der einzige Schlüssel-Marker war ein kurzer synthetischer Text im bestehenden Gitea-Redaktionstest. `.env`, Android-Firebase-Konfiguration und die vorgesehenen Schlüsseldateinamen sind Git-ignoriert. Diese Prüfungen verwenden keinen Pre-Production-Key und belegen weder Server-IAM noch Zustellung auf dem Pre-Production-Gerät. Native Android-Dateien wurden nicht geändert; ein neuer APK-Build war dafür nicht erforderlich. Compose-Konfigurationen für Basis, Local-Push und Pre-Production wurden mit synthetischen Konfigurationswerten geprüft, einschließlich Abbruch bei fehlendem Projekt/Secret-Pfad. Das Backend-Image wurde lokal gebaut und auf Credential-Dateien/Schlüssel geprüft; der einzige Schlüssel-Marker war ein kurzer synthetischer Text im bestehenden Gitea-Redaktionstest. `.env`, Android-Firebase-Konfiguration und die vorgesehenen Schlüsseldateinamen sind Git-ignoriert. Diese Prüfungen verwenden keinen Pre-Production-Key und belegen weder Server-IAM noch Zustellung auf dem Pre-Production-Gerät. Native Android-Dateien wurden nicht geändert; ein neuer APK-Build war dafür nicht erforderlich.
+1 -1
View File
@@ -38,4 +38,4 @@ Ein [fertiger ChatGPT-Prompt](Firebase-Setup-Prompt.md) begleitet die Einrichtun
## Pre-Production Deployment ## Pre-Production Deployment
[Firebase → Pre-Production Deployment](Firebase.md#pre-production-deployment) beschreibt die vorbereitete Compose-Konfiguration, den ausschließlich dort verwendeten Service Account, Secret-Mount und Offline-Preflight sowie den Smoke-Test einschließlich Direktnachrichten und sicherer Gerätebeobachtung. Ein erfolgreicher SDK-Aufruf allein ist kein Android-Empfangsnachweis. [Deployment](Deployment.md) beschreibt die externe `preprod.env`, geschützte Env-Backups und die verpflichtenden Firebase-/Gitea-Preflights vor dem Containerwechsel. [Firebase → Pre-Production Deployment](Firebase.md#pre-production-deployment) ergänzt Service Account, Secret-Mount, IAM und die drei Smoke-Tests einschließlich Direktnachrichten und sicherer Gerätebeobachtung. Ein erfolgreicher SDK-Aufruf allein ist kein Android-Empfangsnachweis.
+61 -18
View File
@@ -1,7 +1,29 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set +x
set -Eeuo pipefail set -Eeuo pipefail
umask 077
trap 'status=$?; printf "FEHLER: Deployment in Zeile %s abgebrochen (Exit %s).\n" "$LINENO" "$status" >&2' ERR # The only active Pre-Production env source. Never source the checkout's .env.
ENV_FILE='/home/kai/.config/metalcircle/preprod.env'
DEPLOYMENT_STARTED=0
on_error() {
local status=$?
if [[ "$DEPLOYMENT_STARTED" == 0 ]]; then
printf 'Deployment aborted. Running container unchanged.\n' >&2
else
printf 'Deployment verification failed after the container update. Manual investigation required.\n' >&2
fi
exit "$status"
}
trap on_error ERR
CHECK_ONLY=0
if [[ "${1:-}" == '--check' && "$#" == 1 ]]; then
CHECK_ONLY=1
elif [[ "$#" != 0 ]]; then
printf 'Usage: ./scripts/deploy-preprod.sh [--check]\n' >&2
exit 1
fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
@@ -18,48 +40,69 @@ if [[ "$BRANCH" != "main" ]]; then
exit 1 exit 1
fi fi
if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then WORKTREE_STATUS="$(git status --porcelain --untracked-files=all)"
if [[ -n "$WORKTREE_STATUS" ]]; then
printf 'FEHLER: Working Tree ist nicht sauber. Änderungen zuerst committen oder entfernen.\n' >&2 printf 'FEHLER: Working Tree ist nicht sauber. Änderungen zuerst committen oder entfernen.\n' >&2
exit 1 exit 1
fi fi
printf 'Aktualisiere main mit Fast-Forward ...\n' if [[ "$CHECK_ONLY" == 0 ]]; then
git pull --ff-only origin main printf 'Aktualisiere main mit Fast-Forward ...\n'
git pull --ff-only origin main
fi
host_check() {
python3 "$SCRIPT_DIR/preprod_config.py" --env-file "$ENV_FILE" "$@"
}
compose() { compose() {
sudo docker compose -f compose.yml -f compose.preprod.yml "$@" # Executes sudo docker compose --env-file "$ENV_FILE" -f compose.yml -f compose.preprod.yml.
# The helper clears ambient overrides and suppresses secret-bearing raw diagnostics.
host_check compose "$@"
} }
run_preflight() { run_preflight() {
local output "$@" python push_preflight.py \
output="$("$@" python push_preflight.py \ --expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com
--expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com)" "$@" python gitea_preflight.py
printf '%s\n' "$output"
if [[ "$output" != PASS:* ]]; then
printf 'FEHLER: Push-Preflight hat kein PASS geliefert.\n' >&2
return 1
fi
} }
printf 'Prüfe externe Environment-Datei und Host-Secret ...\n'
host_check check
CONFIG_FINGERPRINT="$(host_check fingerprint)"
printf 'Prüfe Pre-Production-Compose-Konfiguration ...\n' printf 'Prüfe Pre-Production-Compose-Konfiguration ...\n'
compose config --quiet compose config --quiet
printf 'Baue Web-Image ...\n' printf 'Baue Web-Image ...\n'
compose build web compose build web
printf 'Prüfe Firebase-Credential im temporären Container ...\n' printf 'Prüfe Firebase und Gitea im temporären Container ...\n'
run_preflight compose run --rm --no-deps web run_preflight compose run --rm --no-deps -T web
if [[ "$(host_check fingerprint)" != "$CONFIG_FINGERPRINT" ]]; then
printf 'ERROR: Environment or Firebase secret changed during deployment. Start again.\n' >&2
false
fi
if [[ "$CHECK_ONLY" == 1 ]]; then
printf 'PASS: Pre-Production preflights completed; running container unchanged.\n'
exit 0
fi
printf 'Sichere geprüfte Environment-Datei außerhalb des Checkouts ...\n'
host_check backup
printf 'Aktualisiere ausschließlich den Webcontainer ...\n' printf 'Aktualisiere ausschließlich den Webcontainer ...\n'
DEPLOYMENT_STARTED=1
compose up -d --no-deps web compose up -d --no-deps web
printf 'Prüfe Firebase-Credential im laufenden Webcontainer ...\n' printf 'Prüfe Firebase und Gitea im laufenden Webcontainer ...\n'
run_preflight compose exec -T web run_preflight compose exec -T web
printf '\nCompose-Status:\n' printf '\nCompose-Status:\n'
compose ps compose ps --format json
printf '\nWeb-Logs der letzten 2 Minuten (maximal 100 Zeilen):\n' printf '\nWeb-Logs der letzten 2 Minuten (maximal 100 Zeilen, sicher gefiltert):\n'
compose logs --since=2m --tail=100 --no-color web compose logs --since=2m --tail=100 --no-color web
printf '\nDeployter Git-Commit:\n' printf '\nDeployter Git-Commit:\n'
+205
View File
@@ -0,0 +1,205 @@
"""Host-side deployment checks and secret-safe Compose execution (Python stdlib only)."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
REQUIRED = (
'POSTGRES_DB', 'POSTGRES_USER', 'POSTGRES_PASSWORD',
'INITIAL_ADMIN_USERNAME', 'INITIAL_ADMIN_PASSWORD', 'INITIAL_ADMIN_EMAIL',
'GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO',
'PUSH_ENABLED', 'FIREBASE_PROJECT_ID', 'FIREBASE_SERVICE_ACCOUNT_FILE', 'COOKIE_SECURE',
)
REPO_ROOT = Path(__file__).resolve().parent.parent
class PreprodError(Exception):
"""Only fixed messages or variable names; never external diagnostic text."""
def private_file(path, repo_root, label):
try:
if not path.is_absolute() or path.resolve().is_relative_to(repo_root.resolve()):
raise PreprodError(label + ' must be an absolute path outside the repository')
info = path.lstat()
if not stat.S_ISREG(info.st_mode):
raise PreprodError(label + ' must be a regular file, not a symlink')
if info.st_uid != os.getuid():
raise PreprodError(label + ' must belong to the deployment user')
if stat.S_IMODE(info.st_mode) not in (0o400, 0o600):
raise PreprodError(label + ' requires permissions 600 or 400')
except OSError:
raise PreprodError(label + ' is missing or unreadable') from None
def compose_environment():
# Shell exports must not override the explicit env file (Compose precedence).
# In particular, neither COMPOSE_* nor DOCKER_* can select a different stack/host.
return {name: os.environ[name] for name in
('PATH', 'HOME', 'USER', 'LOGNAME', 'TERM', 'LANG', 'LC_ALL') if name in os.environ}
def invoke_compose(env_file, args, repo_root=REPO_ROOT):
try:
return subprocess.run(
['sudo', 'docker', 'compose', '--env-file', str(env_file),
'-f', 'compose.yml', '-f', 'compose.preprod.yml', *args],
cwd=repo_root, env=compose_environment(), capture_output=True, text=True,
)
except (OSError, UnicodeError):
raise PreprodError('cannot execute local sudo docker compose') from None
def check_configuration(env_file, repo_root=REPO_ROOT):
private_file(env_file, repo_root, 'Environment file')
# Let Compose parse dotenv quoting/escapes/interpolation, not Bash or a second parser.
# Values are captured in memory only. Never print this command's output or stderr.
result = invoke_compose(env_file, ['config', '--no-interpolate', '--environment'], repo_root)
if result.returncode:
raise PreprodError('Compose cannot read the environment file; check syntax and Compose installation')
values = {}
for line in result.stdout.splitlines():
name, separator, value = line.partition('=')
if not separator or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or name in values:
raise PreprodError('environment values must be single-line values')
values[name] = value
for name in REQUIRED:
if not values.get(name, '').strip():
raise PreprodError('required variable ' + name + ' is missing')
# Compose itself adds these two metadata entries even with an empty env file.
metadata = {'COMPOSE_PROJECT_NAME', 'DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND'}
if (any(name.startswith(('COMPOSE_', 'DOCKER_')) and name not in metadata for name in values) or
values.get('COMPOSE_PROJECT_NAME', repo_root.name) != repo_root.name):
raise PreprodError('COMPOSE_* and DOCKER_* overrides are not allowed in preprod.env')
for name in ('COOKIE_SECURE', 'PUSH_ENABLED'):
if values[name].lower() != 'true':
raise PreprodError(name + ' must be true for normal Pre-Production deployment')
if values['FIREBASE_PROJECT_ID'] != 'metalcircle-30d9b':
raise PreprodError('FIREBASE_PROJECT_ID does not match the Pre-Production project')
private_file(Path(values['FIREBASE_SERVICE_ACCOUNT_FILE']), repo_root, 'Firebase secret')
return values
def backup_environment(env_file, repo_root=REPO_ROOT):
private_file(env_file, repo_root, 'Environment file')
directory = env_file.parent / 'backups'
directory.mkdir(mode=0o700, exist_ok=True)
info = directory.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or
stat.S_IMODE(info.st_mode) != 0o700):
raise PreprodError('backup directory must belong to the deployment user and have permissions 700')
name = 'preprod.env.' + datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')
target = directory / name
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, 'wb') as output:
os.fchmod(output.fileno(), 0o600)
output.write(env_file.read_bytes())
output.flush()
os.fsync(output.fileno())
except OSError:
target.unlink(missing_ok=True)
raise
backups = sorted(path for path in directory.iterdir()
if re.fullmatch(r'preprod\.env\.\d{8}-\d{6}-\d{6}', path.name))
for old in backups[:-10]:
private_file(old, repo_root, 'Environment backup')
old.unlink()
print('PASS: environment backup saved outside the repository; last 10 retained.')
def safe_logs(output):
"""Allowlist lifecycle/error categories; omit request paths and all free-form text."""
count = 0
for line in output.splitlines():
request = re.search(r'"(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) .* HTTP/[0-9.]+" (\d{3})', line)
category = re.search(r'Gitea issue submission failed: (wrong_account|configuration|invalid_response|'
r'invalid_attachment_response|network_or_timeout|HTTP [0-9]{3})\s*$', line)
push_error = re.search(r'Push delivery failed: (configuration|unregistered|transient|permanent)\s*$', line)
lifecycle = next((text for text in ('Application startup complete.', 'Application shutdown complete.',
'Waiting for application startup.', 'Shutting down')
if line.rstrip().endswith(text)), None)
if request:
print('HTTP ' + request[1] + ' ' + request[2])
elif category:
print('Gitea: ' + category[1])
elif push_error:
print('Push: ' + push_error[1])
elif lifecycle:
print(lifecycle)
else:
count += 1
if count:
print(str(count) + ' other log lines omitted (secret/privacy protection).')
def run_compose(env_file, args):
if not args or args[0] not in {'config', 'build', 'run', 'up', 'exec', 'ps', 'logs'}:
raise PreprodError('unsupported deployment Compose command')
private_file(env_file, REPO_ROOT, 'Environment file')
result = invoke_compose(env_file, args)
preflight = args[0] in {'run', 'exec'} and any(
item in args for item in ('push_preflight.py', 'gitea_preflight.py'))
if preflight:
# Only our dedicated preflights' safe stdout; Docker stderr remains private.
for line in result.stdout.splitlines():
if line.startswith(('PASS: ', 'FAIL: ')):
print(line)
if (result.returncode or not result.stdout.startswith('PASS: ') or
any(line.startswith('FAIL: ') for line in result.stdout.splitlines())):
raise PreprodError('container preflight failed; deployment stopped')
elif result.returncode:
raise PreprodError('Compose ' + args[0] + ' failed; raw diagnostics suppressed to protect secrets')
elif args[0] == 'logs':
safe_logs(result.stdout)
elif args[0] == 'ps':
# Compose versions return either JSON arrays or one JSON object per line.
try:
rows = json.loads(result.stdout) if result.stdout.lstrip().startswith('[') else [
json.loads(line) for line in result.stdout.splitlines() if line.strip()]
for row in rows:
print('Container:', row['Name'], '| state:', row['State'])
except (ValueError, KeyError, TypeError):
raise PreprodError('cannot parse Compose container status') from None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--env-file', type=Path, required=True)
parser.add_argument('action', choices=('check', 'fingerprint', 'backup', 'compose'))
parser.add_argument('compose_args', nargs=argparse.REMAINDER)
args = parser.parse_args()
try:
if args.action == 'check':
check_configuration(args.env_file)
for name in REQUIRED:
print(name + ': set')
print('PASS: environment and host secret checks.')
elif args.action == 'fingerprint':
# Captured by the shell, never displayed. Detect edits during build/preflights.
values = check_configuration(args.env_file)
digest = hashlib.sha256(args.env_file.read_bytes())
digest.update(b'\x00')
digest.update(Path(values['FIREBASE_SERVICE_ACCOUNT_FILE']).read_bytes())
print(digest.hexdigest())
elif args.action == 'backup':
backup_environment(args.env_file)
else:
run_compose(args.env_file, args.compose_args)
except PreprodError as error:
print('ERROR: ' + str(error))
return 1
except (OSError, ValueError):
print('ERROR: host file operation failed; check permissions and available disk space')
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
+174
View File
@@ -0,0 +1,174 @@
"""Run the real shell/helper in a disposable checkout with fake Git/Docker commands."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import preprod_config
class DeploymentOrderingTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.repo = self.root / 'pingu-concerts'
(self.repo / 'scripts').mkdir(parents=True)
self.env = self.root / 'preprod.env'
self.secret = self.root / 'firebase.json'
self.secret.write_text('synthetic test bytes, not a credential')
self.secret.chmod(0o600)
self.values = {name: 'synthetic-private-value' for name in preprod_config.REQUIRED}
self.values.update(PUSH_ENABLED='true', COOKIE_SECURE='true', FIREBASE_PROJECT_ID='metalcircle-30d9b',
FIREBASE_SERVICE_ACCOUNT_FILE=str(self.secret))
self.write_env()
source = Path(__file__).resolve().parents[1]
script = (source / 'deploy-preprod.sh').read_text().replace(
"ENV_FILE='/home/kai/.config/metalcircle/preprod.env'", 'ENV_FILE=' + repr(str(self.env)))
(self.repo / 'scripts/deploy-preprod.sh').write_text(script)
shutil.copy(source / 'preprod_config.py', self.repo / 'scripts')
self.control = self.root / 'control.json'
self.control.write_text('{}')
self.trace = self.root / 'trace.jsonl'
self.bin = self.root / 'bin'
self.bin.mkdir()
shared = (f'CONTROL = {str(self.control)!r}\nTRACE = {str(self.trace)!r}\n'
'import json, sys\nfrom pathlib import Path\n'
'control = json.loads(Path(CONTROL).read_text())\n'
'args = sys.argv[1:]\n'
'with open(TRACE, "a") as trace: trace.write(json.dumps([Path(sys.argv[0]).name, *args]) + "\\n")\n')
self.executable('git', shared + f'''
if args == ['rev-parse', '--show-toplevel']: print({str(self.repo)!r})
elif args == ['branch', '--show-current']: print(control.get('branch', 'main'))
elif args[0] == 'status':
if control.get('fail') == 'status': sys.exit(1)
print(control.get('dirty', ''))
elif args[0] == 'log': print('abc123 synthetic test commit')
elif args[0] == 'pull' and control.get('fail') == 'pull': sys.exit(1)
''')
self.executable('sudo', shared + '''
assert args[:3] == ['docker', 'compose', '--env-file']
env_file = Path(args[3])
assert args[4:8] == ['-f', 'compose.yml', '-f', 'compose.preprod.yml']
action = args[8:]
step = action[0]
if step in ('run', 'exec'):
step += '-firebase' if 'push_preflight.py' in action else '-gitea'
if control.get('no_pass') == step:
print('synthetic-private-value')
sys.exit(0)
if control.get('fail') == step:
print('FAIL: gitea_http_403' if 'gitea' in step else 'FAIL: credential_service_account_mismatch')
print('synthetic-private-value', file=sys.stderr)
sys.exit(1)
if action == ['config', '--no-interpolate', '--environment']:
print(env_file.read_text())
elif action[0] in ('run', 'exec'):
print('PASS: synthetic preflight')
if control.get('edit') and step == 'run-gitea':
env_file.write_text(env_file.read_text() + '\\nCHANGED=true\\n')
elif action[0] == 'logs':
print('private synthetic-private-value')
print('web | INFO \\"POST /private/synthetic-private-value HTTP/1.1\\" 200 OK')
elif action[0] == 'ps': print(json.dumps([{'Name': 'pingu-concerts-web', 'State': 'running'}]))
''')
def executable(self, name, code):
path = self.bin / name
path.write_text('#!/usr/bin/env python3\n' + code)
path.chmod(0o700)
def write_env(self):
self.env.write_text('\n'.join(k + '=' + v for k, v in self.values.items()))
self.env.chmod(0o600)
def run_deploy(self, *args, **controls):
self.control.write_text(json.dumps(controls))
self.trace.write_text('')
result = subprocess.run(['bash', str(self.repo / 'scripts/deploy-preprod.sh'), *args],
cwd=self.root, env={**os.environ, 'PATH': str(self.bin) + ':' + os.environ['PATH']},
capture_output=True, text=True)
self.assertNotIn('synthetic-private-value', result.stdout + result.stderr)
commands = [json.loads(line) for line in self.trace.read_text().splitlines()]
self.actions = [cmd[9:] for cmd in commands if cmd[0] == 'sudo']
return result
def assert_no_update(self, result):
self.assertNotEqual(result.returncode, 0)
self.assertFalse(any(action[0] == 'up' for action in self.actions))
def test_success_order_and_non_destructive_commands(self):
result = self.run_deploy()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
update = self.actions.index(['up', '-d', '--no-deps', 'web'])
preflights = [a for a in self.actions[:update] if a[0] == 'run']
self.assertEqual(len(preflights), 2)
self.assertTrue(all('--no-deps' in a and '--rm' in a for a in preflights))
self.assertIn('push_preflight.py', preflights[0])
self.assertIn('gitea_preflight.py', preflights[1])
self.assertEqual(len([a for a in self.actions[update + 1:] if a[0] == 'exec']), 2)
self.assertEqual(len(list((self.root / 'backups').glob('preprod.env.*'))), 1)
self.assertTrue(all(not set(a) & {'down', 'rm', '--force-recreate', 'db'} for a in self.actions))
def test_missing_env_secret_or_any_gitea_value_never_updates(self):
for missing in ('GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO'):
with self.subTest(missing=missing):
saved = self.values.pop(missing)
self.write_env()
result = self.run_deploy()
self.assert_no_update(result)
self.assertIn('ERROR: required variable ' + missing + ' is missing', result.stdout)
self.assertIn('Running container unchanged.', result.stderr)
self.values[missing] = saved
self.write_env()
self.env.unlink()
self.assert_no_update(self.run_deploy())
self.write_env()
self.secret.unlink()
self.assert_no_update(self.run_deploy())
def test_every_failure_before_up_keeps_running_container(self):
for fail in ('status', 'pull', 'config', 'build', 'run-firebase', 'run-gitea'):
with self.subTest(fail=fail):
result = self.run_deploy(fail=fail)
self.assert_no_update(result)
self.assertIn('Running container unchanged.', result.stderr)
self.assertFalse((self.root / 'backups').exists())
def test_dirty_tree_and_wrong_branch_abort_without_compose(self):
for controls in ({'branch': 'feature'}, {'dirty': ' M changed-file'}):
result = self.run_deploy(**controls)
self.assert_no_update(result)
self.assertEqual(self.actions, [])
def test_check_only_never_updates_or_backs_up(self):
result = self.run_deploy('--check')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(any(a[0] in ('up', 'exec') for a in self.actions))
self.assertFalse((self.root / 'backups').exists())
def test_config_edit_during_preflight_aborts(self):
result = self.run_deploy(edit=True)
self.assert_no_update(result)
self.assertIn('changed during deployment', result.stderr)
def test_zero_exit_without_pass_is_not_sufficient(self):
for step in ('run-firebase', 'run-gitea'):
with self.subTest(step=step):
self.assert_no_update(self.run_deploy(no_pass=step))
def test_backup_failure_aborts_before_update(self):
(self.root / 'backups').mkdir(mode=0o755)
self.assert_no_update(self.run_deploy())
def test_post_update_failure_does_not_claim_old_container_unchanged(self):
result = self.run_deploy(fail='exec-gitea')
self.assertNotEqual(result.returncode, 0)
self.assertIn('Manual investigation required.', result.stderr)
self.assertNotIn('Running container unchanged.', result.stderr)
self.assertEqual(len([a for a in self.actions if a[0] == 'up']), 1)
+173
View File
@@ -0,0 +1,173 @@
from contextlib import redirect_stdout
from io import StringIO
import os
from pathlib import Path
import shutil
import stat
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import preprod_config as config
class HostConfigurationTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.repo = self.root / 'pingu-concerts'
self.repo.mkdir()
self.env = self.root / 'preprod.env'
self.env.write_text('synthetic fixture only')
self.env.chmod(0o600)
self.secret = self.root / 'firebase.json'
self.secret.write_text('synthetic fixture only; not credentials')
self.secret.chmod(0o600)
self.values = {name: 'test-value' for name in config.REQUIRED}
self.values.update(PUSH_ENABLED='true', COOKIE_SECURE='true', FIREBASE_PROJECT_ID='metalcircle-30d9b',
FIREBASE_SERVICE_ACCOUNT_FILE=str(self.secret),
COMPOSE_PROJECT_NAME='pingu-concerts', DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND='docker compose')
def check(self):
response = SimpleNamespace(returncode=0, stdout='\n'.join(k + '=' + v for k, v in self.values.items()))
with patch.object(config, 'invoke_compose', return_value=response):
return config.check_configuration(self.env, self.repo)
def test_valid_configuration_and_auto_compose_metadata(self):
self.assertEqual(self.check()['PUSH_ENABLED'], 'true')
def test_each_missing_or_blank_required_variable_is_named_without_values(self):
for name in config.REQUIRED:
for value in ('', ' '):
with self.subTest(name=name, value=value), patch.dict(self.values, {name: value}):
with self.assertRaisesRegex(config.PreprodError, '^required variable ' + name + ' is missing$'):
self.check()
def test_missing_env_and_missing_firebase_secret(self):
for path, expected in ((self.env, 'Environment file'), (self.secret, 'Firebase secret')):
saved = path.read_bytes()
path.unlink()
with self.assertRaisesRegex(config.PreprodError, expected + ' is missing'):
self.check()
path.write_bytes(saved)
path.chmod(0o600)
def test_permissions_symlinks_and_repository_paths_are_rejected(self):
for path in (self.env, self.secret):
path.chmod(0o644)
with self.assertRaisesRegex(config.PreprodError, 'requires permissions'):
self.check()
path.chmod(0o600)
inside = self.repo / 'private.json'
inside.write_text('fixture')
inside.chmod(0o600)
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(inside)
with self.assertRaisesRegex(config.PreprodError, 'outside the repository'):
self.check()
linked = self.root / 'linked.json'
linked.symlink_to(self.secret)
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(linked)
with self.assertRaisesRegex(config.PreprodError, 'not a symlink'):
self.check()
def test_wrong_owner_and_directory_fail(self):
self.values['FIREBASE_SERVICE_ACCOUNT_FILE'] = str(self.root)
with self.assertRaisesRegex(config.PreprodError, 'regular file'):
self.check()
with patch('preprod_config.os.getuid', return_value=os.getuid() + 1):
with self.assertRaisesRegex(config.PreprodError, 'deployment user'):
config.private_file(self.env, self.repo, 'Environment file')
def test_disabled_push_insecure_cookies_wrong_project_and_context_overrides_fail(self):
for name, value in (('PUSH_ENABLED', 'false'), ('COOKIE_SECURE', 'false'),
('FIREBASE_PROJECT_ID', 'other'), ('COMPOSE_PROJECT_NAME', 'other'),
('DOCKER_HOST', 'tcp://elsewhere'), ('COMPOSE_ENV_FILES', '.env')):
with self.subTest(name=name), patch.dict(self.values, {name: value}):
with self.assertRaises(config.PreprodError):
self.check()
def test_compose_parse_errors_never_expose_stderr(self):
response = SimpleNamespace(returncode=1, stderr='synthetic-secret', stdout='synthetic-secret')
with patch.object(config, 'invoke_compose', return_value=response):
with self.assertRaises(config.PreprodError) as caught:
config.check_configuration(self.env, self.repo)
self.assertNotIn('synthetic-secret', str(caught.exception))
def test_backups_are_private_keep_ten_and_preserve_unrelated_files(self):
directory = self.env.parent / 'backups'
directory.mkdir(mode=0o700)
unrelated = directory / 'keep-me'
unrelated.write_text('unrelated')
with redirect_stdout(StringIO()):
for _ in range(12):
config.backup_environment(self.env, self.repo)
backups = list(directory.glob('preprod.env.*'))
self.assertEqual(len(backups), 10)
self.assertEqual(stat.S_IMODE(directory.stat().st_mode), 0o700)
self.assertTrue(unrelated.exists())
for backup in backups:
self.assertEqual(stat.S_IMODE(backup.stat().st_mode), 0o600)
self.assertEqual(backup.read_bytes(), self.env.read_bytes())
def test_backup_refuses_insecure_directory(self):
directory = self.env.parent / 'backups'
directory.mkdir(mode=0o755)
with self.assertRaisesRegex(config.PreprodError, 'permissions 700'):
config.backup_environment(self.env, self.repo)
def test_compose_cannot_inherit_shell_secrets_or_remote_docker_context(self):
with patch.dict(os.environ, {'GITEA_TOKEN': 'stale', 'DOCKER_HOST': 'remote', 'COMPOSE_FILE': 'other'}):
cleaned = config.compose_environment()
for name in ('GITEA_TOKEN', 'DOCKER_HOST', 'COMPOSE_FILE'):
self.assertNotIn(name, cleaned)
def test_safe_logs_discard_private_text_and_request_urls(self):
output = StringIO()
with redirect_stdout(output):
config.safe_logs('web | INFO "POST /reset/synthetic-secret HTTP/1.1" 200 OK\n'
'web | private synthetic-secret\n'
'web | Gitea issue submission failed: HTTP 403\n'
'web | Push delivery failed: configuration\n')
self.assertNotIn('synthetic-secret', output.getvalue())
self.assertIn('HTTP POST 200', output.getvalue())
self.assertIn('Gitea: HTTP 403', output.getvalue())
self.assertIn('Push: configuration', output.getvalue())
@unittest.skipUnless(shutil.which('docker'), 'requires Compose CLI only; no daemon/network')
def test_real_compose_config_requires_gitea_even_without_host_helper(self):
def validate():
self.env.write_text('\n'.join(k + '=' + v for k, v in self.values.items()
if not k.startswith(('COMPOSE_', 'DOCKER_'))))
return subprocess.run(['docker', 'compose', '--env-file', str(self.env),
'-f', str(config.REPO_ROOT / 'compose.yml'),
'-f', str(config.REPO_ROOT / 'compose.preprod.yml'), 'config', '--quiet'],
env=config.compose_environment(), capture_output=True, text=True)
self.assertEqual(validate().returncode, 0)
for name in ('GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO'):
with self.subTest(name=name), patch.dict(self.values, {name: ''}):
result = validate()
self.assertNotEqual(result.returncode, 0)
self.assertIn('required variable ' + name + ' is missing', result.stderr)
@unittest.skipUnless(shutil.which('docker'), 'requires Compose CLI only; no daemon/network')
def test_real_compose_dotenv_quoting_and_no_checkout_or_shell_fallback(self):
self.values['GITEA_TOKEN'] = "'synthetic-$literal#value'"
self.env.write_text('\n'.join(k + '=' + v for k, v in self.values.items()
if not k.startswith(('COMPOSE_', 'DOCKER_'))))
def invoke(env_file, args, repo_root):
return subprocess.run(['docker', 'compose', '--env-file', str(env_file),
'-f', str(config.REPO_ROOT / 'compose.yml'),
'-f', str(config.REPO_ROOT / 'compose.preprod.yml'), *args],
env=config.compose_environment(), capture_output=True, text=True)
with patch.object(config, 'invoke_compose', side_effect=invoke):
checked = config.check_configuration(self.env, config.REPO_ROOT)
self.assertEqual(checked['GITEA_TOKEN'], 'synthetic-$literal#value')
self.env.write_text(self.env.read_text().replace("GITEA_TOKEN='synthetic-$literal#value'", ''))
with patch.dict(os.environ, {'GITEA_TOKEN': 'must-not-be-used'}):
with self.assertRaisesRegex(config.PreprodError, 'required variable GITEA_TOKEN is missing'):
config.check_configuration(self.env, config.REPO_ROOT)