Harden pre-production deployment with external environment and preflights
This commit is contained in:
+2
-1
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+59
-16
@@ -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
|
||||||
|
|
||||||
|
if [[ "$CHECK_ONLY" == 0 ]]; then
|
||||||
printf 'Aktualisiere main mit Fast-Forward ...\n'
|
printf 'Aktualisiere main mit Fast-Forward ...\n'
|
||||||
git pull --ff-only origin main
|
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'
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user