Git-Deploy per Tag, Betriebsüberwachung und Release-Seeder für den Livegang

- dev/betrieb/deploy.sh: Deploy nur per Tag, mit --seed (Seeder im Wartungsmodus)
  und --initial (Vorschau/Live einmalig an Ort und Stelle auf Git umstellen)
- dev/betrieb/vorschau-auffrischen.sh: Live-DB und Live-Medien in die Vorschau (Generalprobe)
- InteriorReleaseSeeder: alle Inhaltsschritte des Relaunchs in einem Lauf, idempotent;
  Displays, Playlists und Mediathek bleiben unberührt
- DisplayInteriorBrandingSeeder überschreibt keinen inzwischen gepflegten Claim mehr
- Monitoring: GlitchTip (Server + Browser), /health mit Token, Scheduler-Herzschlag,
  Alarme per Mail und ntfy, Version in about und /health
- public/build versioniert; .gitignore: macOS-Muster Icon traf resources/views/flux/icon/
- Runbook: dev/betrieb/README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 08:56:23 +00:00
parent fcb10be120
commit b5ed756d67
98 changed files with 3947 additions and 82 deletions

View file

@ -2492,3 +2492,28 @@ korrigiert – u. a. Hinweisleiste (über B2IN_ANNOUNCEMENT_BAR aus, Feld „ena
**Nachbesserung 08.10.:** Hero mobil – die Infokarte steht unter dem Bild (Bild 4:3 oben, Karte als weißer
Streifen darunter), ab lg schwebt sie wie bisher unten links. Karte weiß statt `bg-card`, damit die
Glass-Flow-Regel `[class*="card"]` nicht greift; `absolute!` entfällt.
---
## Nachtrag 32 · Git-Deploy, Monitoring und Ablauf für den Livegang (08.10.2026)
- **Git-Deploy per Tag:** `dev/betrieb/deploy.sh` (aus `dev/git-deploy-monitoring/`) liefert nur Tags aus,
`origin` = forge.adametz.media, `NAS` = Sicherung. `public/build` ist jetzt versioniert. Neu:
`--seed=<Klasse>` (Seeder im Wartungsmodus) und `--initial` (Vorschau und Live einmalig an Ort und
Stelle auf Git umstellen, mit Code-Sicherung und Parken der Nur-Server-Dateien).
`.gitignore`: Das macOS-Muster `Icon` traf `resources/views/flux/icon/`, die vier Icons wären mit
Git-Deploy auf Live verloren gegangen; das Muster heißt jetzt `Icon[\r]`.
- **Monitoring:** GlitchTip (Server und Browser, Website und Portal), `/health` mit Token (jede Domain,
ohne Basic Auth), Scheduler-Herzschlag, Alarme bei fehlgeschlagenen Tasks per Mail und ntfy,
Version in `php artisan about` und `/health`. Pakete: `sentry/sentry-laravel`, `@sentry/browser`.
- **Livegang ohne Datenmigration:** Die Live-Datenbank bleibt die Grundlage (Displays, Playlists,
Mediathek, Benutzer), `InteriorReleaseSeeder` bringt die Website-Inhalte in einem Lauf auf den neuen
Stand. Er fasst alle Seeder aus 8.2 und „Seeder für das nächste Deployment“ zusammen, ist idempotent
(Test) und lässt Display-Daten unberührt. Ausnahme: Branding, nur bei Modulen ohne Interior-Logo;
`DisplayInteriorBrandingSeeder` überschreibt seit heute keinen inzwischen gepflegten Claim mehr.
Generalprobe: `dev/betrieb/vorschau-auffrischen.sh` kopiert Live-DB und Live-Medien in die Vorschau.
- **Ablauf:** `dev/betrieb/README.md`. Er ersetzt für den Livegang die Einzelbefehle aus 8.2 und die
Seeder-Liste oben. Die Prüfliste 8.3 gilt weiter.
- **Offen:** Datenschutzerklärung um die Fehlerprotokollierung ergänzen, bevor die DSNs auf Live gesetzt
werden (Textvorschlag in `dev/betrieb/README.md`, Abschnitt 6).

235
dev/betrieb/README.md Normal file
View file

@ -0,0 +1,235 @@
# Betrieb B2in: Git-Deploy, Livegang, Monitoring
**Stand:** 08.10.2026. Grundlage ist der Baukasten unter `dev/git-deploy-monitoring/`. Dort steht,
warum etwas so gebaut ist. Hier steht, wie es für B2in läuft.
| Was | Wo |
|---|---|
| Deploy-Skript | `dev/betrieb/deploy.sh`, liefert nur Git-Tags `vX.Y.Z` aus |
| Vorschau auffrischen | `dev/betrieb/vorschau-auffrischen.sh` kopiert Live-DB und Live-Medien in die Vorschau |
| Inhaltsschritte Relaunch | `InteriorReleaseSeeder`, alle 29 Seeder in einem Lauf, idempotent |
| Remote `origin` | lokal `ssh://git@forge.adametz.media:222/kadmin/b2in.git`, auf dem Server `forge-b2in:kadmin/b2in.git` (Host-Alias, Abschnitt 2): Deploy-Quelle für Vorschau und Live |
| Remote `NAS` | `ssh://git@git.adametz.media:222/gitmedia/b2in.git`: lokale Sicherung, wird nie deployt |
| Fehlerprotokoll | GlitchTip `https://errors.adametz.media`, Organisation `adametz-media`, Projekte `server-b2in` (ID 8) und `browser-b2in` (ID 7) |
| Push-Alarme | ntfy `https://push.adametz.media`, Topic `b2in-ops` |
| Externer Monitor | Uptime Kuma auf `/health` (mit Token) und `/up` je Domain |
---
## 1. Release lokal (jedes Mal)
```bash
git status # nichts offen
php artisan test --compact
npm ci && npm run build:web && npm run build:portal # public/build ist versioniert, der Server hat kein Node
git add public/build && git diff --cached --quiet public/build || git commit -m "build: Assets für vX.Y.Z"
git tag -a vX.Y.Z -m "vX.Y.Z – <Inhalt>
Deploy: <keine Migration | N Migrationen>, <Seeder>, <neue .env-Werte>
bash dev/betrieb/deploy.sh vX.Y.Z [--migrations] [--seed=…]"
git push origin <branch> --follow-tags
git push NAS <branch> --follow-tags # Sicherung
```
Versionen folgen SemVer: Ein Feature-Release erhöht die mittlere Stelle, ein Hotfix die letzte.
Der Interior-Relaunch ist `v2.0.0`.
---
## 2. Einmalig auf dem Server
Als App-Benutzer, gilt für Vorschau und Live (gleicher Server):
1. **Deploy-Key**, nur Leserecht am Repository auf forge.adametz.media. Auf dem Server liegen mehrere
Deploy-Keys für denselben Forgejo (B2in, Breco), deshalb gibt es je Projekt einen Host-Alias.
Die Repository-Adresse heißt dann **`forge-b2in:kadmin/b2in.git`**, nicht `ssh://git@forge.adametz.media:222/…`;
mit der vollen Adresse greift kein Alias, und ssh bietet keinen Schlüssel an („Permission denied (publickey)“).
```bash
ssh-keygen -t ed25519 -f ~/.ssh/deploy_b2in -N "" -C "b2in-server"
cat ~/.ssh/deploy_b2in.pub # in Forgejo: kadmin/b2in ▸ Einstellungen ▸ Deploy-Schlüssel, ohne Schreibrecht
```
`~/.ssh/config`:
```
Host forge-b2in
HostName forge.adametz.media
Port 222
User git
IdentityFile ~/.ssh/deploy_b2in
IdentitiesOnly yes
HostKeyAlias forge.adametz.media
```
Test: `git ls-remote forge-b2in:kadmin/b2in.git` zeigt die Tags.
2. **Flux Pro für Composer:** `deploy.sh` führt `composer install` aus. Dafür braucht der Server die
Flux-Zugangsdaten, also `auth.json` im App-Verzeichnis (ignoriert, bleibt liegen) oder
`composer config --global http-basic.composer.fluxui.dev <e-mail> <lizenz>`.
3. **PHP-FPM-Reload ohne Passwort testen:** `echo "" | sudo -S service php8.4-fpm reload; echo $?`.
Ergibt das Exit 0, lädt das Skript FPM selbst neu. Fragt sudo nach einem Passwort, beim Deploy
`DEPLOY_FPM_RELOAD=` setzen; dann wartet das Skript, bis FPM im Panel neu geladen ist. Heißt
der Dienst anders: `DEPLOY_PHP_FPM_SERVICE=…`. **Immer Reload, nie Restart.**
4. **Cron für den Scheduler:** `* * * * * cd <app> && php artisan schedule:run >> /dev/null 2>&1`.
Er trägt den Herzschlag für `/health` und `cabinet:reset-overrides`. Eine Queue braucht B2in
nicht; der Worker-Test im Skript ist aus.
5. **Server-Panel:** „Quick Deploy“ und Push-Webhooks bleiben aus. Ein Push ist noch kein Release.
### `.env`-Werte je Umgebung
| Schlüssel | Live | Vorschau |
|---|---|---|
| `SENTRY_LARAVEL_DSN`, `SENTRY_BROWSER_DSN` | wie lokal (Projekte server-b2in / browser-b2in), **erst nach der Datenschutzerklärung (Abschnitt 6)** | gleich, optional |
| `SENTRY_ENVIRONMENT` | `production` | `preview` |
| `MONITORING_NTFY_URL`, `_TOPIC`, `_TOKEN` | wie lokal | leer (keine Push-Alarme aus der Vorschau) |
| `MONITORING_ALERT_EMAIL` | Ops-Postfach | leer |
| `MONITORING_HEALTH_TOKEN` | **eigener Wert** (`openssl rand -hex 24`), nicht der lokale | eigener Wert oder leer |
| `MAIL_MAILER` | wie bisher | `log` (die Vorschau hat Live-Daten) |
`APP_VERSION` bleibt leer. Die Version kommt aus der Datei `RELEASE`, die das Skript schreibt.
---
## 3. Livegang Interior-Relaunch (v2.0.0)
**Grundidee:** Auf Live hat der Kunde Displays, Playlists und die Mediathek gepflegt, an der Website
im CMS aber nichts geändert. Deshalb kopieren wir **nicht** die Vorschau-Datenbank nach Live und
übertragen Display-Daten einzeln. Stattdessen bleibt die Live-Datenbank die Grundlage, und
`InteriorReleaseSeeder` bringt deren Website-Inhalte auf den neuen Stand. Displays,
Display-Mediathek, CMS-Mediathek, Benutzer und hochgeladene Dateien bleiben unberührt. Angepasst
werden nur:
- das Display-Branding, und zwar nur bei Modulen, die noch kein Interior-Logo tragen
- absolute Medien-URLs, die auf `b2in.test` oder `b2in.eu` zeigen; sie werden host-unabhängig
Damit der Kunde genau dieses Ergebnis vorher sieht, läuft derselbe Ablauf zuerst auf der Vorschau
mit einer frischen Kopie der Live-Daten (Generalprobe).
### 3.1 Generalprobe auf der Vorschau
```bash
# Skripte aus dem Tag holen (Vorschau und Live sind noch keine Git-Checkouts)
git clone -q --depth 1 --branch v2.0.0 forge-b2in:kadmin/b2in.git /tmp/b2in-v2.0.0
# 1. Live-Daten und Live-Medien in die Vorschau (Live wird nur gelesen; legt ~/b2in-live-<zeit>.sql.gz an)
bash /tmp/b2in-v2.0.0/dev/betrieb/vorschau-auffrischen.sh <live> <vorschau>
# 2. Vorschau einmalig auf Git umstellen und v2.0.0 ausliefern, mit allen Inhaltsschritten
cd <vorschau>
bash /tmp/b2in-v2.0.0/dev/betrieb/deploy.sh v2.0.0 --initial --migrations --seed=InteriorReleaseSeeder
```
`--initial` sichert vorher den Code nach `~/<verzeichnis>-vor-git-<zeit>.tgz`. Dann zeigt es die
Dateien, die es nur auf dem Server gibt (Reste aus SFTP- und Archiv-Updates). Erst nach einem
`ja` verschiebt es sie nach `~/<verzeichnis>-vor-git-<zeit>/`. Danach ist `git status` leer.
Vorschau prüfen: die Liste unter 8.3 im Entwicklungsplan, dazu im Portal der Vorschau die
**Displays und Playlists mit Live vergleichen**. Dort müssen dieselben Module, Bilder und Videos
erscheinen. Neue Generalprobe, etwa nach einem Hotfix-Tag oder weil auf Live weiter gepflegt
wurde:
```bash
cd <vorschau> && bash dev/betrieb/deploy.sh v2.0.1 --migrations # nur bei neuem Tag
bash dev/betrieb/vorschau-auffrischen.sh <live> <vorschau> --seed=InteriorReleaseSeeder
```
### 3.2 Livegang nach der Freigabe
1. Wartungsfenster mit Marcel abstimmen. Die Displays holen ihre Konfiguration zyklisch, laufen
also weiter, ein Wechsel im Laden ist aber sichtbar.
2. **DB-Backup von Live:**
`mysqldump --single-transaction --no-tablespaces -u … -p b2in_live | gzip > ~/b2in-live-vor-v2.sql.gz`
3. Live-`.env` ergänzen (Tabelle oben). `SENTRY_*` erst nach Abschnitt 6.
4. Gleicher Befehl wie auf der Vorschau, nur im Live-Verzeichnis:
```bash
cd <live>
bash /tmp/b2in-v2.0.0/dev/betrieb/deploy.sh v2.0.0 --initial --migrations --seed=InteriorReleaseSeeder
rm -rf /tmp/b2in-v2.0.0
```
5. Prüfen: `php artisan about --only=b2in` zeigt `v2.0.0`. `/health` mit Token liefert
`"status":"ok"` (der Herzschlag braucht nach dem Deploy bis zu einer Minute). Danach die
Liste 8.3 im Entwicklungsplan, die vier Displays sichten, `storage/logs` und GlitchTip auf neue
Fehler prüfen.
**Rückweg** (nur Live, falls etwas grundlegend nicht passt): Seite in den Wartungsmodus nehmen,
`.git` entfernen, die Code-Sicherung `~/<live>-vor-git-<zeit>.tgz` zurückspielen, das DB-Backup
aus Schritt 2 einspielen und FPM neu laden. Die geparkten Nur-Server-Dateien aus
`~/<live>-vor-git-<zeit>/` liegen ebenfalls noch vor.
---
## 4. Folge-Deploys (ab v2.0.0)
```bash
cd <app>
bash dev/betrieb/deploy.sh vX.Y.Z # ohne Migrationen
bash dev/betrieb/deploy.sh vX.Y.Z --migrations # mit Migrationen, vorher DB-Backup
bash dev/betrieb/deploy.sh vX.Y.Z --seed=<Seeder> # Inhaltsschritt laut Release-Notiz
bash dev/betrieb/deploy.sh <vorheriger Tag> # Rollback
```
Das Skript bricht ab, **bevor** es etwas ändert, wenn:
- im Verzeichnis jemand von Hand etwas geändert hat
- der Tag fehlt
- ein Build-Manifest fehlt
- neue Migrationen ohne `--migrations` kommen
Auf dem Server wird nichts von Hand geändert, auch Hotfixes laufen über Commit, Tag und Deploy.
Exit-Codes: `0` fertig · `1` abgebrochen · `2` falscher Aufruf.
---
## 5. Monitoring
Eingebaut nach `dev/git-deploy-monitoring/` (02 und 03), Tests unter `tests/Feature/Monitoring/`.
| Ausfall | Wer meldet | Kanal |
|---|---|---|
| Exception (Request, Command, Livewire) | Sentry-SDK | GlitchTip `server-b2in` |
| JavaScript-Fehler (Website und Portal) | `resources/js/error-reporting.js` | GlitchTip `browser-b2in` (Tag `area`: `web`/`portal`) |
| Toter interner Link, 419, 403/429 für Angemeldete | `ReportErrorSignals` | GlitchTip (Warnung) |
| Geplanter Task fehlgeschlagen | `onFailure` in `routes/console.php` | Log `monitoring`, Mail, ntfy |
| Cron steht, DB/Cache weg, Platte voll | `/health` | Uptime Kuma, dann ntfy und Mail |
| Server, FPM, Nginx oder SSL weg | `/up` nicht erreichbar | Uptime Kuma |
**Uptime Kuma** (status.adametz.media):
| Monitor | Ziel | Intervall |
|---|---|---|
| B2in /health | `https://b2in.eu/health`, Header `{"Authorization": "Bearer <MONITORING_HEALTH_TOKEN von Live>"}`, Status 200–299 | 60 s, 2 Wiederholungen |
| B2in /up | `https://b2in.eu/up` und `https://portal.b2in.eu/up` | 5 min |
| Zertifikate | Ablaufwarnung 14 Tage vorher | – |
`/health` antwortet auf jeder Domain, ist von der Basic Auth ausgenommen und liefert ohne oder
mit falschem Token 404.
**Abnahme auf Live:**
- `php artisan tinker --execute 'App\Support\OperationalAlert::send("Test B2in");'`: Push und Mail kommen an
- `php artisan tinker --execute 'report(new RuntimeException("Test B2in"));'`: erscheint in `server-b2in`, Umgebung `production`, Release `v2.0.0`
- Browser-Konsole auf b2in.eu: `setTimeout(() => { throw new Error('Test test@example.com') })` erscheint in `browser-b2in`, die E-Mail als `[email]`
- Uptime Kuma: Ein Test-Monitor mit falschem Token löst einen Alarm aus, danach löschen
**GlitchTip auswerten** (lokal, Lese-Token `GLITCHTIP_API_TOKEN` in der `.env`): Die Befehle stehen
in `dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md`, Abschnitt 7, mit
`B=https://errors.adametz.media/api/0`, `ORG=adametz-media`, Projekt `8` (Server) bzw. `7` (Browser).
Lokal sind die DSNs gesetzt (`SENTRY_ENVIRONMENT=local`). Fehler beim Entwickeln landen also in
GlitchTip, aber getrennt nach Umgebung. Die Testsuite meldet nie etwas; `phpunit.xml` leert DSNs
und ntfy.
---
## 6. Datenschutzerklärung (vor dem Einschalten der DSNs auf Live)
Die Fehlerprotokollierung muss in die Datenschutzerklärung, bevor auf Live `SENTRY_*` gesetzt wird.
Hier ein Textvorschlag, den Marcel bzw. die Rechtsberatung freigeben muss:
> **Fehlerprotokollierung.** Tritt beim Aufruf unserer Website ein technischer Fehler auf,
> speichern wir dazu die aufgerufene Adresse (ohne Formularinhalte und ohne Zugangs-Token), Browser
> und Betriebssystem, die Fehlermeldung und, bei angemeldeten Nutzern, deren interne Nutzer-ID.
> IP-Adressen und Cookies werden nicht übermittelt. Die Daten verarbeiten wir auf einem eigenen
> Server in Deutschland, um Fehler zu erkennen und zu beheben. Rechtsgrundlage ist unser
> berechtigtes Interesse am sicheren und fehlerfreien Betrieb (Art. 6 Abs. 1 lit. f DSGVO). Die
> Einträge werden nach 90 Tagen gelöscht.
Vor der Freigabe prüfen: Serverstandort und Aufbewahrungsfrist (`GLITCHTIP_RETENTION_DAYS`).
Eingetragen wird der Text in `resources/lang/de/b2in_legal.php` und im CMS unter „Rechtliches“.

294
dev/betrieb/deploy.sh Executable file
View file

@ -0,0 +1,294 @@
#!/usr/bin/env bash
#
# Deploy eines Git-Tags auf Vorschau oder Live (B2in). Grundlage: Baukasten
# dev/git-deploy-monitoring, Ablauf für B2in: dev/betrieb/README.md.
#
# Aufruf im App-Verzeichnis auf dem Server:
#
# bash dev/betrieb/deploy.sh v2.0.0 # ohne neue Migrationen
# bash dev/betrieb/deploy.sh v2.0.0 --migrations # mit Migrationen, DB-Backup vorher!
# bash dev/betrieb/deploy.sh v2.0.0 --migrations --seed=InteriorReleaseSeeder
#
# Optionen:
# --migrations Neue Migrationen ausführen. Ohne diese Option bricht das
# Skript ab, sobald der Tag neue Migrationen mitbringt.
# --clear-schedule php artisan schedule:clear-cache (alte withoutOverlapping-Sperren).
# --seed=<Klasse> Seeder im Wartungsmodus ausführen (nach den Migrationen,
# vor den Caches) – für Inhaltsschritte, die die Release-Notiz nennt.
# --initial Einmalig: ein Verzeichnis ohne Git (bisher SFTP/Archiv) an Ort und
# Stelle zum Checkout des Tags machen. Sichert vorher den Code nach
# ~/<verzeichnis>-vor-git-<zeit>.tgz, zeigt Dateien, die es nur auf dem
# Server gibt, und verschiebt sie nach Rückfrage nach ~/<verzeichnis>-vor-git-<zeit>/.
# Verlangt --migrations (vorher DB-Backup).
#
# Umgebung (optional):
# DEPLOY_FPM_RELOAD Befehl zum Neuladen von PHP-FPM (Standard siehe unten).
# Leer setzen, wenn FPM von Hand im Server-Panel neu geladen
# wird — dann wartet das Skript auf Enter. Nie „Restart“:
# der entfernt kurz den Socket, Nginx liefert 502.
# DEPLOY_PHP_FPM_SERVICE systemd-Name des FPM-Dienstes (Standard php8.4-fpm).
# DEPLOY_REPOSITORY Repository für --initial (Standard: Host-Alias forge-b2in aus ~/.ssh/config).
#
# Exit-Codes: 0 fertig, 1 abgebrochen (siehe Ausgabe), 2 falscher Aufruf,
# 3 live, aber kein Queue-Worker hat den Test-Job abgeholt.
#
# Rollback: dasselbe Skript mit dem vorherigen Tag. Nach --initial: Sicherung
# zurückspielen (Pfad steht in der Ausgabe) und DB-Backup einspielen.
set -euo pipefail
# ---- Projekt-Einstellungen --------------------------------------------------
DEPLOY_SCRIPT="dev/betrieb/deploy.sh" # Pfad dieses Skripts im Repository
PHP_FPM_SERVICE="${DEPLOY_PHP_FPM_SERVICE:-php8.4-fpm}" # systemd-Name des FPM-Dienstes
ABOUT_SECTION="b2in" # AboutCommand::add('B2in', …) im AppServiceProvider
QUEUE_PROBE=0 # B2in stellt keine Jobs in die Queue (Mails gehen sofort raus)
REPOSITORY="${DEPLOY_REPOSITORY:-forge-b2in:kadmin/b2in.git}"
# -----------------------------------------------------------------------------
# Git ersetzt diese Datei beim Checkout. Bash liest Skripte stückweise, deshalb
# läuft der Deploy aus einer Kopie.
if [[ -z "${DEPLOY_COPY:-}" ]]; then
copy="$(mktemp)"
cp "$0" "$copy"
DEPLOY_COPY="$copy" exec bash "$copy" "$@"
fi
trap 'rm -f "$DEPLOY_COPY"' EXIT
tag="${1:-}"
shift || true
run_migrations=0
clear_schedule=0
initial=0
seeders=()
for option in "$@"; do
case "$option" in
--migrations) run_migrations=1 ;;
--clear-schedule) clear_schedule=1 ;;
--initial) initial=1 ;;
--seed=*)
seeder="${option#--seed=}"
if [[ ! "$seeder" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
echo "Ungültiger Seeder: $seeder" >&2; exit 2
fi
seeders+=("$seeder") ;;
*) echo "Unbekannte Option: $option" >&2; exit 2 ;;
esac
done
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Aufruf: bash $DEPLOY_SCRIPT vX.Y.Z [--migrations] [--seed=<Klasse>] [--clear-schedule] [--initial]" >&2
exit 2
fi
if [[ ! -f artisan ]]; then
echo "Im App-Verzeichnis aufrufen (artisan fehlt hier)." >&2
exit 2
fi
step() { printf '\n==> %s\n' "$*"; }
if [[ "$initial" -eq 1 ]]; then
if [[ -d .git ]]; then
echo "--initial nur für ein Verzeichnis ohne Git – hier gibt es .git schon." >&2
exit 2
fi
if [[ "$run_migrations" -ne 1 ]]; then
echo "--initial verlangt --migrations (vorher DB-Backup ziehen)." >&2
exit 2
fi
stamp="$(date +%Y-%m-%d-%H%M)"
site="$(basename "$PWD")"
backup="$HOME/$site-vor-git-$stamp.tgz"
parked="$HOME/$site-vor-git-$stamp"
step "Sicherung des Codes nach $backup (ohne storage/, node_modules/)"
# Exit 1 = „Datei hat sich beim Lesen geändert“ (Logs o. Ä.) – die Sicherung ist trotzdem brauchbar.
tar czf "$backup" --exclude=./storage --exclude=./node_modules --exclude=./public/storage . || [[ $? -eq 1 ]]
ls -lh "$backup"
step "Git einrichten, Tag $tag holen"
git init -q
git remote add origin "$REPOSITORY"
if ! git fetch --quiet --tags origin || ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then
rm -rf .git
echo "Tag $tag nicht abrufbar (Deploy-Key? Tag gepusht?). .git wieder entfernt, nichts verändert." >&2
exit 1
fi
git update-ref --no-deref HEAD "$(git rev-parse "$tag^{commit}")"
git read-tree HEAD
# Ignorier-Regeln des Tags gelten schon jetzt (die .gitignore im Verzeichnis ist noch die alte).
git show "$tag:.gitignore" > .git/info/exclude
changed="$(git status --porcelain --untracked-files=no | wc -l | tr -d ' ')"
server_only="$(git -c core.quotePath=false ls-files --others --exclude-standard)"
echo "$changed Dateien weichen vom Tag ab oder fehlen – der Checkout ersetzt sie."
if [[ -n "$server_only" ]]; then
echo
echo "Diese $(echo "$server_only" | wc -l | tr -d ' ') Dateien gibt es nur auf dem Server (nicht im Tag, nicht ignoriert):"
echo "$server_only" | sed 's/^/ /' | head -200
echo
echo "Sie werden nach $parked/ verschoben (Pfade bleiben erhalten). Wird eine davon gebraucht,"
echo "jetzt abbrechen und sie ins Repository oder in die .gitignore aufnehmen (neuer Tag)."
fi
read -r -p "Weiter mit dem Deploy von $tag? [ja/nein] " answer
if [[ "$answer" != "ja" ]]; then
rm -rf .git
echo "Abgebrochen. .git wieder entfernt, nichts verändert (Sicherung bleibt liegen)." >&2
exit 1
fi
previous="vor-git (Sicherung: $backup)"
new_migrations="(Umstellung: alle offenen Migrationen)"
else
if [[ ! -d .git ]]; then
echo "Hier gibt es kein .git – für die einmalige Umstellung --initial verwenden." >&2
exit 2
fi
step "Arbeitsverzeichnis prüfen"
drift="$(git status --porcelain)"
if [[ -n "$drift" ]]; then
echo "Das Arbeitsverzeichnis weicht vom ausgecheckten Stand ab:" >&2
echo "$drift" >&2
echo "Erst klären (git diff), dann deployen. Nichts wurde verändert." >&2
exit 1
fi
step "Tags von origin holen"
git fetch --quiet --tags origin
if ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then
echo "Tag $tag gibt es auf origin nicht." >&2
exit 1
fi
previous="$(cat RELEASE 2>/dev/null || git describe --tags --always)"
if [[ "$(git rev-parse HEAD)" == "$(git rev-parse "$tag^{commit}")" ]]; then
echo "$tag ist bereits ausgecheckt. Nichts zu tun."
exit 0
fi
fi
echo "Bisher: $previous → Ziel: $tag"
for build in b2in b2a stileigentum style2own portal; do
if ! git cat-file -e "$tag:public/build/$build/manifest.json" 2> /dev/null; then
echo "public/build/$build/manifest.json fehlt im Tag $tag – Assets lokal bauen und committen. Nichts wurde verändert." >&2
exit 1
fi
done
if [[ "$initial" -ne 1 ]]; then
new_migrations="$(git diff --name-only --diff-filter=A HEAD "$tag" -- database/migrations)"
fi
if [[ -n "$new_migrations" ]]; then
echo "Neue Migrationen:"
echo "$new_migrations" | sed 's/^/ /'
if [[ "$run_migrations" -ne 1 ]]; then
echo "Abbruch: DB-Backup ziehen und mit --migrations erneut aufrufen. Nichts wurde verändert." >&2
exit 1
fi
fi
if [[ "$initial" -eq 1 ]]; then
trap 'echo; echo "!! Umstellung abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rückweg: Verzeichnis aus $backup wiederherstellen (vorher .git entfernen), DB-Backup einspielen." >&2; rm -f "$DEPLOY_COPY"' ERR
else
trap 'echo; echo "!! Deploy abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rollback: bash $DEPLOY_SCRIPT $previous" >&2; rm -f "$DEPLOY_COPY"' ERR
fi
step "Wartungsmodus"
php artisan down --retry=60
step "Checkout $tag"
if [[ "$initial" -eq 1 ]]; then
if [[ -n "$server_only" ]]; then
while IFS= read -r file; do
mkdir -p "$parked/$(dirname "$file")"
mv "$file" "$parked/$file"
done <<< "$server_only"
echo "Nur-Server-Dateien verschoben nach $parked/"
fi
git checkout --quiet --force --detach "$tag"
: > .git/info/exclude
else
git checkout --quiet --detach "$tag"
fi
echo "$tag" > RELEASE
step "Composer"
composer install --no-dev --optimize-autoloader --no-interaction
if [[ -n "$new_migrations" ]]; then
step "Migrationen"
php artisan migrate --force
fi
for seeder in "${seeders[@]}"; do
step "Seeder $seeder"
php artisan db:seed --class="$seeder" --force
done
step "Öffentliche Assets"
if [[ ! -e public/storage ]]; then
php artisan storage:link
fi
if [[ -d public/vendor/livewire ]]; then
php artisan vendor:publish --tag=livewire:assets --force
fi
# Statisch ausgelieferte Flux-Skripte passend zur installierten Flux-Version
if [[ -d public/flux ]]; then
php artisan flux:publish-assets --force
fi
step "Caches"
php artisan config:cache
php artisan route:cache
php artisan view:clear
php artisan view:cache
php artisan event:cache
if [[ "$clear_schedule" -eq 1 ]]; then
php artisan schedule:clear-cache
fi
step "Worker und PHP-FPM"
php artisan queue:restart
default_fpm_reload="echo '' | sudo -S service $PHP_FPM_SERVICE reload"
fpm_reload="${DEPLOY_FPM_RELOAD-$default_fpm_reload}"
if [[ -n "$fpm_reload" ]]; then
bash -c "$fpm_reload"
else
echo "PHP-FPM jetzt im Server-Panel neu laden (Reload, nicht Restart), danach Enter."
read -r
fi
step "Wartungsmodus aus"
php artisan up
step "Version"
if [[ -n "$ABOUT_SECTION" ]]; then
php artisan about --only="$ABOUT_SECTION"
else
cat RELEASE
fi
trap - ERR
# Läuft ein Worker? `queue:restart` oben beendet ihn; neu starten muss ihn
# Supervisor. Läuft Supervisor nicht, steht die Queue still, ohne dass es
# jemand merkt.
if [[ "$QUEUE_PROBE" -eq 1 ]]; then
step "Queue-Worker"
if ! php artisan monitoring:queue-probe --wait=60; then
echo
echo "!! Deploy $tag ist live, aber KEIN Queue-Worker läuft." >&2
echo "!! Jetzt Supervisor und den Worker im Server-Panel prüfen — sonst bleiben Jobs und Mails liegen." >&2
exit 3
fi
fi
echo
echo "Deploy $tag fertig (vorher $previous). Weiter mit den Prüfungen aus der Deploy-Anleitung."

View file

@ -0,0 +1,175 @@
#!/usr/bin/env bash
#
# Vorschau mit den aktuellen Live-Daten auffrischen (B2in). Ablauf: dev/betrieb/README.md.
#
# Kopiert die komplette Live-Datenbank und die hochgeladenen Medien (storage/app/public:
# Display-Mediathek, CMS-Mediathek, Videos, QR-Codes) in die Vorschau. Danach steht auf der
# Vorschau genau das, was der Kunde auf Live gepflegt hat – Displays, Playlists, Mediathek –,
# und der Release-Seeder kann darauf zur Probe laufen.
#
# Live wird nur gelesen (mysqldump --single-transaction, rsync als Quelle). Die Vorschau-
# Datenbank wird vollständig ersetzt.
#
# Aufruf (auf dem Server, als App-Benutzer):
#
# bash dev/betrieb/vorschau-auffrischen.sh <live-verzeichnis> <vorschau-verzeichnis> [--seed=<Klasse>]
#
# --seed=<Klasse> nach dem Import (und den Migrationen) diesen Seeder auf der Vorschau ausführen,
# z. B. --seed=InteriorReleaseSeeder für eine erneute Generalprobe.
#
# Die Live-Sicherung bleibt als ~/b2in-live-<zeit>.sql.gz liegen.
set -euo pipefail
live="${1:-}"
preview="${2:-}"
shift 2 || true
seeders=()
for option in "$@"; do
case "$option" in
--seed=*)
seeder="${option#--seed=}"
if [[ ! "$seeder" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
echo "Ungültiger Seeder: $seeder" >&2; exit 2
fi
seeders+=("$seeder") ;;
*) echo "Unbekannte Option: $option" >&2; exit 2 ;;
esac
done
if [[ -z "$live" || -z "$preview" || ! -f "$live/artisan" || ! -f "$preview/artisan" ]]; then
echo "Aufruf: bash $0 <live-verzeichnis> <vorschau-verzeichnis> [--seed=<Klasse>]" >&2
exit 2
fi
live="$(cd "$live" && pwd)"
preview="$(cd "$preview" && pwd)"
if [[ "$live" == "$preview" ]]; then
echo "Live- und Vorschau-Verzeichnis sind dasselbe." >&2
exit 2
fi
step() { printf '\n==> %s\n' "$*"; }
# Wert aus einer .env lesen (ohne Anführungszeichen, ohne Kommentar am Zeilenende bei unquotierten Werten)
env_value() {
local file="$1" key="$2" line value
line="$(grep -E "^${key}=" "$file" | tail -n 1 || true)"
value="${line#*=}"
if [[ "$value" =~ ^\"(.*)\"$ || "$value" =~ ^\'(.*)\'$ ]]; then
value="${BASH_REMATCH[1]}"
else
value="${value%% #*}"
fi
printf '%s' "$value"
}
# MySQL-Zugangsdaten als Datei (chmod 600), damit das Passwort nicht in der Prozessliste steht
credentials() {
local env="$1/.env" file
file="$(mktemp)"
chmod 600 "$file"
{
echo "[client]"
echo "host=$(env_value "$env" DB_HOST)"
echo "port=$(env_value "$env" DB_PORT)"
echo "user=$(env_value "$env" DB_USERNAME)"
echo "password=\"$(env_value "$env" DB_PASSWORD)\""
} > "$file"
printf '%s' "$file"
}
live_db="$(env_value "$live/.env" DB_DATABASE)"
preview_db="$(env_value "$preview/.env" DB_DATABASE)"
live_host="$(env_value "$live/.env" DB_HOST)"
preview_host="$(env_value "$preview/.env" DB_HOST)"
if [[ -z "$live_db" || -z "$preview_db" ]]; then
echo "DB_DATABASE fehlt in einer der beiden .env-Dateien." >&2
exit 1
fi
if [[ "$live_db" == "$preview_db" && "$live_host" == "$preview_host" ]]; then
echo "STOPP: Vorschau und Live zeigen auf dieselbe Datenbank ($live_db). Nichts wurde verändert." >&2
exit 1
fi
if [[ "$(env_value "$preview/.env" APP_ENV)" == "production" ]]; then
echo "STOPP: Die Vorschau läuft mit APP_ENV=production – ist das wirklich die Vorschau? Nichts wurde verändert." >&2
exit 1
fi
mailer="$(env_value "$preview/.env" MAIL_MAILER)"
if [[ "$mailer" != "log" && "$mailer" != "array" ]]; then
echo "Hinweis: MAIL_MAILER der Vorschau ist „$mailer“. Mit den Live-Daten könnte die Vorschau echte Mails verschicken."
echo "Empfohlen: MAIL_MAILER=log in $preview/.env."
fi
echo "Live: $live (Datenbank $live_db)"
echo "Vorschau: $preview (Datenbank $preview_db – wird vollständig ersetzt)"
echo "Medien: $live/storage/app/public/ → $preview/storage/app/public/ (Vorschau-eigene Dateien werden entfernt)"
read -r -p "Weiter? [ja/nein] " answer
if [[ "$answer" != "ja" ]]; then
echo "Abgebrochen, nichts verändert."
exit 1
fi
live_credentials="$(credentials "$live")"
preview_credentials="$(credentials "$preview")"
trap 'rm -f "$live_credentials" "$preview_credentials"' EXIT
dump="$HOME/b2in-live-$(date +%Y-%m-%d-%H%M).sql.gz"
step "Live-Datenbank sichern nach $dump"
mysqldump --defaults-extra-file="$live_credentials" --single-transaction --quick --no-tablespaces \
--routines --triggers --default-character-set=utf8mb4 "$live_db" | gzip > "$dump"
ls -lh "$dump"
step "Vorschau in den Wartungsmodus"
(cd "$preview" && php artisan down --retry=60)
trap 'echo; echo "!! Abgebrochen. Die Vorschau bleibt im Wartungsmodus; Live ist unverändert." >&2; rm -f "$live_credentials" "$preview_credentials"' ERR
step "Vorschau-Datenbank leeren und Live-Stand einspielen"
(cd "$preview" && php artisan db:wipe --force)
gunzip -c "$dump" | mysql --defaults-extra-file="$preview_credentials" --default-character-set=utf8mb4 "$preview_db"
# Sitzungen und Cache von Live gehören nicht in die Vorschau
for table in sessions cache cache_locks jobs; do
if mysql --defaults-extra-file="$preview_credentials" -N -e "SHOW TABLES LIKE '$table'" "$preview_db" | grep -q .; then
mysql --defaults-extra-file="$preview_credentials" -e "TRUNCATE TABLE \`$table\`" "$preview_db"
fi
done
step "Medien übernehmen"
mkdir -p "$preview/storage/app/public"
if [[ "$(realpath "$live/storage/app/public")" == "$(realpath "$preview/storage/app/public")" ]]; then
# Vorschau verlinkt auf die Live-Medien (preview-deployment.md, Schritt 3) – nichts zu kopieren
echo "Die Vorschau nutzt die Live-Medien per Link – übersprungen."
else
rsync -a --delete "$live/storage/app/public/" "$preview/storage/app/public/"
du -sh "$preview/storage/app/public"
fi
step "Migrationen und Seeder"
cd "$preview"
php artisan migrate --force
for seeder in "${seeders[@]}"; do
php artisan db:seed --class="$seeder" --force
done
step "Caches"
[[ -e public/storage ]] || php artisan storage:link
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
step "Vorschau wieder online"
php artisan up
trap - ERR
echo
echo "Vorschau aufgefrischt: Daten und Medien von Live, Stand $(date '+%d.%m.%Y %H:%M')."

View file

@ -82,28 +82,28 @@ Auf dem Server als App-Benutzer (bei Ploi `ploi`):
2. Deploy-Key erzeugen und im Git-Server **nur lesend** am Repository eintragen:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/deploy_meineapp -N "" -C "meineapp-live"
cat ~/.ssh/deploy_meineapp.pub
ssh-keygen -t ed25519 -f ~/.ssh/deploy_b2inapp -N "" -C "b2inapp-live"
cat ~/.ssh/deploy_b2inapp.pub
```
3. `~/.ssh/config`:
```
Host git.example.com
Host forge.adametz.media
Port 222
User git
IdentityFile ~/.ssh/deploy_meineapp
IdentityFile ~/.ssh/deploy_b2inapp
IdentitiesOnly yes
```
4. Host-Key aufnehmen, Fingerprint mit dem im Git-Server angezeigten vergleichen:
```bash
ssh-keyscan -p 222 git.example.com >> ~/.ssh/known_hosts
ssh-keygen -lf <(ssh-keyscan -p 222 git.example.com 2>/dev/null)
ssh-keyscan -p 222 forge.adametz.media >> ~/.ssh/known_hosts
ssh-keygen -lf <(ssh-keyscan -p 222 forge.adametz.media 2>/dev/null)
```
5. Test: `git ls-remote ssh://git@git.example.com:222/team/meineapp.git` zeigt die Tags.
5. Test: `git ls-remote ssh://git@forge.adametz.media :222/kadmin/b2in.git` zeigt die Tags.
6. **FPM-Reload ohne Passwort testen** (Ploi erlaubt das dem Benutzer `ploi`):
```bash