From 2813795c9db9e7e6b7d2ff336ec817028d1fef68 Mon Sep 17 00:00:00 2001 From: MetalCircle Codex Bot Date: Wed, 16 Sep 2026 00:22:19 +0200 Subject: [PATCH] Harden pre-production deployment with external environment and preflights --- .env.example | 3 +- .gitignore | 5 + README.md | 4 + app/.dockerignore | 4 + app/gitea_preflight.py | 62 ++++++++ app/tests/test_gitea_preflight.py | 77 ++++++++++ app/tests/test_push_preflight.py | 5 + compose.preprod.yml | 8 ++ config/preprod.env.example | 29 ++++ docs/wiki/Configuration.md | 8 +- docs/wiki/Deployment.md | 178 +++++++++++++++++++++-- docs/wiki/Firebase.md | 62 +++----- docs/wiki/Push-Notifications.md | 2 +- scripts/deploy-preprod.sh | 79 ++++++++--- scripts/preprod_config.py | 205 +++++++++++++++++++++++++++ scripts/tests/test_deploy_preprod.py | 174 +++++++++++++++++++++++ scripts/tests/test_preprod_config.py | 173 ++++++++++++++++++++++ 17 files changed, 1002 insertions(+), 76 deletions(-) create mode 100644 app/gitea_preflight.py create mode 100644 app/tests/test_gitea_preflight.py create mode 100644 config/preprod.env.example create mode 100644 scripts/preprod_config.py create mode 100644 scripts/tests/test_deploy_preprod.py create mode 100644 scripts/tests/test_preprod_config.py diff --git a/.env.example b/.env.example index 682a2a7..a8bd82f 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,8 @@ GITEA_OWNER=kai GITEA_REPO=pingu-concerts # 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". PUSH_ENABLED=false FIREBASE_PROJECT_ID= diff --git a/.gitignore b/.gitignore index c08b406..3979939 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ env/ .env .env.* !.env.example +*.env +*.env.* +!*.env.example +*.swp +*.swo compose.dev.yml # Logs diff --git a/README.md b/README.md index bf8a698..6bae32b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ db/init/ Initialschema für eine neue PostgreSQL-Datenbank db/migrations/ Nachträgliche, reproduzierbare Schemaänderungen android/ Capacitor-Projekt und Android-App compose*.yml Containerdefinitionen und optionale Push-/Pre-Production-Konfiguration +config/ Vorlagen ohne Secrets +scripts/ Deployment und lokale Prüfungen 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. +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 ```bash diff --git a/app/.dockerignore b/app/.dockerignore index 52a9fce..77ca0be 100644 --- a/app/.dockerignore +++ b/app/.dockerignore @@ -1,5 +1,9 @@ .env .env.* +*.env +*.env.* +*.swp +*.swo secrets/ **/*service-account*.json **/*service_account*.json diff --git a/app/gitea_preflight.py b/app/gitea_preflight.py new file mode 100644 index 0000000..8ce34b8 --- /dev/null +++ b/app/gitea_preflight.py @@ -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()) diff --git a/app/tests/test_gitea_preflight.py b/app/tests/test_gitea_preflight.py new file mode 100644 index 0000000..8164557 --- /dev/null +++ b/app/tests/test_gitea_preflight.py @@ -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') diff --git a/app/tests/test_push_preflight.py b/app/tests/test_push_preflight.py index ed2eee0..95c943b 100644 --- a/app/tests/test_push_preflight.py +++ b/app/tests/test_push_preflight.py @@ -73,3 +73,8 @@ class PushPreflightTests(unittest.TestCase): with self.subTest(target=target), patch(target, side_effect=ValueError('sensitive SDK details')): with self.assertRaisesRegex(push_preflight.PreflightError, '^credential_missing_unreadable_or_invalid$'): 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) diff --git a/compose.preprod.yml b/compose.preprod.yml index 62793ec..0dfa474 100644 --- a/compose.preprod.yml +++ b/compose.preprod.yml @@ -6,6 +6,14 @@ services: file: compose.push.yml service: web 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" FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:?Set the Pre-Production Firebase project ID} COOKIE_SECURE: "true" diff --git a/config/preprod.env.example b/config/preprod.env.example new file mode 100644 index 0000000..8c4e145 --- /dev/null +++ b/config/preprod.env.example @@ -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 diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 396e90d..8ec2593 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -5,9 +5,9 @@ | `POSTGRES_DB` | Name der PostgreSQL-Datenbank | erforderlich | | `POSTGRES_USER` | Datenbankbenutzer | erforderlich | | `POSTGRES_PASSWORD` | Datenbankpasswort | erforderlich, geheim | -| `INITIAL_ADMIN_USERNAME` | Erstes Admin-Konto | lokal erforderlich | -| `INITIAL_ADMIN_PASSWORD` | Passwort des Erstadmins | lokal erforderlich, geheim | -| `INITIAL_ADMIN_EMAIL` | E-Mail des Erstadmins | lokal erforderlich | +| `INITIAL_ADMIN_USERNAME` | Erstes Admin-Konto | lokal und Pre-Production erforderlich | +| `INITIAL_ADMIN_PASSWORD` | Passwort des Erstadmins | lokal und Pre-Production erforderlich, geheim | +| `INITIAL_ADMIN_EMAIL` | E-Mail des Erstadmins | lokal und Pre-Production erforderlich | | `COOKIE_SECURE` | Secure-Flag der Session-Cookies | Produktion `true` | | `GITEA_URL` | interne Gitea-Basisadresse | für Bugreporter erforderlich | | `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. +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. diff --git a/docs/wiki/Deployment.md b/docs/wiki/Deployment.md index 919c6ec..f562c6c 100644 --- a/docs/wiki/Deployment.md +++ b/docs/wiki/Deployment.md @@ -1,15 +1,177 @@ # 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. -2. Aktuelle Pre-Production / Cloud-Staging unter **https://konzerte.pinguholic.de/** für gemeinsame Integrationstests. -3. Produktion. +## Pre-Production: verbindliche Pfade -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 +``` -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). diff --git a/docs/wiki/Firebase.md b/docs/wiki/Firebase.md index d94fbf8..4104eca 100644 --- a/docs/wiki/Firebase.md +++ b/docs/wiki/Firebase.md @@ -46,7 +46,7 @@ Automatisierte Backend-Tests simulieren Firebase und belegen nicht die Cloud-IAM ### 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. @@ -61,66 +61,36 @@ Der lokale Account `metalcircle-push-local@metalcircle-30d9b.iam.gserviceaccount ### 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 -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: +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: ```dotenv PUSH_ENABLED=true 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 ``` -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 - -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: +### B. Deployment im Pre-Production-Checkout ```bash +cd /opt/pingu-concerts +./scripts/deploy-preprod.sh --check ./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 -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' -``` +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. -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. - -`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. +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. ### 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 *`): ```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' 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; @@ -198,6 +168,8 @@ Alle drei Tests zusätzlich mit B in EN wiederholen; ursprüngliche Sprache/Prä ### 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. diff --git a/docs/wiki/Push-Notifications.md b/docs/wiki/Push-Notifications.md index ac21bf8..08bc056 100644 --- a/docs/wiki/Push-Notifications.md +++ b/docs/wiki/Push-Notifications.md @@ -38,4 +38,4 @@ Ein [fertiger ChatGPT-Prompt](Firebase-Setup-Prompt.md) begleitet die Einrichtun ## 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. diff --git a/scripts/deploy-preprod.sh b/scripts/deploy-preprod.sh index 642c1a0..f5501ec 100755 --- a/scripts/deploy-preprod.sh +++ b/scripts/deploy-preprod.sh @@ -1,7 +1,29 @@ #!/usr/bin/env bash +set +x 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)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" @@ -18,48 +40,69 @@ if [[ "$BRANCH" != "main" ]]; then exit 1 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 exit 1 fi -printf 'Aktualisiere main mit Fast-Forward ...\n' -git pull --ff-only origin main +if [[ "$CHECK_ONLY" == 0 ]]; then + 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() { - 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() { - local output - output="$("$@" python push_preflight.py \ - --expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com)" - printf '%s\n' "$output" - if [[ "$output" != PASS:* ]]; then - printf 'FEHLER: Push-Preflight hat kein PASS geliefert.\n' >&2 - return 1 - fi + "$@" python push_preflight.py \ + --expected-service-account metalcircle-push-preprod@metalcircle-30d9b.iam.gserviceaccount.com + "$@" python gitea_preflight.py } +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' compose config --quiet printf 'Baue Web-Image ...\n' compose build web -printf 'Prüfe Firebase-Credential im temporären Container ...\n' -run_preflight compose run --rm --no-deps web +printf 'Prüfe Firebase und Gitea im temporären Container ...\n' +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' +DEPLOYMENT_STARTED=1 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 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 printf '\nDeployter Git-Commit:\n' diff --git a/scripts/preprod_config.py b/scripts/preprod_config.py new file mode 100644 index 0000000..e3abf48 --- /dev/null +++ b/scripts/preprod_config.py @@ -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()) diff --git a/scripts/tests/test_deploy_preprod.py b/scripts/tests/test_deploy_preprod.py new file mode 100644 index 0000000..5e37630 --- /dev/null +++ b/scripts/tests/test_deploy_preprod.py @@ -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) diff --git a/scripts/tests/test_preprod_config.py b/scripts/tests/test_preprod_config.py new file mode 100644 index 0000000..70c6997 --- /dev/null +++ b/scripts/tests/test_preprod_config.py @@ -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)