diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..dbac240 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,142 @@ +name: Deploy + +# Warum diese Datei unter .github/workflows liegt und nicht unter .gitea/: +# +# Gitea Actions liest `.gitea/workflows`, **und nur wenn dieses Verzeichnis +# fehlt**, ersatzweise `.github/workflows`. Ein neu angelegtes `.gitea/` +# würde also ci.yml stillschweigend abschalten — der Lauf verschwände +# einfach, ohne Fehlermeldung. Solange beide Dateien hier liegen, sieht Gitea +# beide. (Auf GitHub bliebe dieser Workflow hängen: es gibt dort keinen +# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf +# git.elycon.solutions.) + +on: + push: + branches: [master] + +# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander +# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose +# up` abgeschnitten zu werden ist der eine Zustand, den man nicht will. +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + +jobs: + deploy: + name: Migrationen und Container + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # Nur was der Migrationsläufer braucht (`pg`), nicht der ganze Baum: + # gebaut wird die Anwendung im Container, nicht hier. + - name: Abhängigkeiten + run: npm ci --omit=dev --ignore-scripts + + # ── .env aus den Gitea-Secrets ────────────────────────────────── + # + # Nicht die .env vom Server lesen: der Job läuft in einem eigenen + # Container, und dessen Dateisystem ist nicht das des Hosts. Die Werte + # kommen deshalb aus den Repository-Secrets und werden hier + # zusammengesetzt. Sie landen in keinem Abbild — docker compose reicht + # sie zur Laufzeit an den Container weiter. + - name: .env schreiben + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL }} + AUTH_MICROSOFT_ENTRA_ID_ID: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ID }} + AUTH_MICROSOFT_ENTRA_ID_SECRET: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_SECRET }} + AUTH_MICROSOFT_ENTRA_ID_ISSUER: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ISSUER }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + set -euo pipefail + fehlend="" + for name in DATABASE_URL SUPABASE_DB_URL AUTH_SECRET AUTH_URL \ + AUTH_MICROSOFT_ENTRA_ID_ID AUTH_MICROSOFT_ENTRA_ID_SECRET \ + AUTH_MICROSOFT_ENTRA_ID_ISSUER CRON_SECRET; do + eval "wert=\${$name:-}" + [ -n "$wert" ] || fehlend="$fehlend $name" + done + if [ -n "$fehlend" ]; then + echo "Diese Secrets fehlen im Repository (Settings → Actions → Secrets):$fehlend" + exit 1 + fi + # printf statt echo: ein Wert, der mit - beginnt, wäre sonst ein Schalter. + { + printf 'DATABASE_URL=%s\n' "$DATABASE_URL" + printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET" + printf 'AUTH_URL=%s\n' "$AUTH_URL" + printf 'AUTH_MICROSOFT_ENTRA_ID_ID=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ID" + printf 'AUTH_MICROSOFT_ENTRA_ID_SECRET=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_SECRET" + printf 'AUTH_MICROSOFT_ENTRA_ID_ISSUER=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ISSUER" + printf 'CRON_SECRET=%s\n' "$CRON_SECRET" + } > .env + chmod 600 .env + + # ── Migrationen ───────────────────────────────────────────────── + # + # Vor dem Neustart, nicht danach: der neue Code erwartet das neue + # Schema. Umgekehrt liefe die neue Anwendung kurz gegen das alte und + # fiele über fehlende Spalten. + # + # Erst zeigen, was ansteht — das steht dann im Protokoll des Laufs, auch + # wenn danach etwas schiefgeht. + - name: Ausstehende Migrationen zeigen + env: + SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + run: node scripts/migrate.mjs --dry-run + + - name: Migrationen anwenden + env: + SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }} + run: node scripts/migrate.mjs + + # ── Container ─────────────────────────────────────────────────── + # + # Läuft gegen den Docker-Dienst des Hosts (der Runner-Container hat + # dessen Socket eingehängt). Der Projektname wird ausdrücklich gesetzt: + # sonst leitet ihn Compose vom Verzeichnisnamen ab, und der ist im + # Arbeitsverzeichnis des Runners ein anderer als bei der ersten + # Installation von Hand — es entstünde ein zweiter Stapel daneben, + # während der alte weiterläuft. + - name: Bauen und starten + env: + COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }} + run: | + set -euo pipefail + docker compose build + docker compose up -d --remove-orphans + + # ── Nachweis ──────────────────────────────────────────────────── + # + # Ohne diesen Schritt gilt ein Deploy als erfolgreich, sobald der + # Container *gestartet* ist — auch wenn die Anwendung darin sofort + # abstürzt. Gewartet wird auf den Healthcheck aus dem Dockerfile + # (GET /login), nicht auf „läuft". + - name: Warten, bis die Anwendung antwortet + env: + COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }} + run: | + set -euo pipefail + for versuch in $(seq 1 30); do + zustand=$(docker compose ps --format '{{.Health}}' app | head -1) + case "$zustand" in + healthy) echo "Gesund nach $versuch Versuchen."; exit 0 ;; + unhealthy) echo "Container meldet unhealthy."; break ;; + esac + sleep 4 + done + echo "Anwendung ist nicht gesund geworden. Letzte Ausgaben:" + docker compose ps + docker compose logs --tail 80 app + exit 1 + + - name: Aufräumen + if: always() + run: rm -f .env diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index bb8269e..9dd1f05 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -219,10 +219,126 @@ Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der Container sieht — und das ist hinter dem Proxy nicht der Name, den der Browser benutzt hat. -## 5. Updates ausrollen +## 5. Automatisch ausrollen bei einem Push (Gitea Actions) + +Ein Push auf `master` baut und startet die Anwendung auf dem Server neu. +Zuständig ist [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml). + +> **Warum `.github/` und nicht `.gitea/`:** Gitea Actions liest +> `.gitea/workflows` — und nur wenn dieses Verzeichnis **fehlt**, ersatzweise +> `.github/workflows`. Ein neu angelegtes `.gitea/workflows/` würde also +> `ci.yml` stillschweigend abschalten. Solange beide Dateien unter `.github/` +> liegen, sieht Gitea beide. + +### a) Runner registrieren + +Der Runner läuft als Container **auf demselben Server** wie die Anwendung und +bekommt den Docker-Socket des Hosts eingehängt — damit baut und startet er die +Container des Hosts, statt welche in sich selbst zu erzeugen. + +Registrierungs-Token holen: Gitea → Repository → *Settings* → *Actions* → +*Runners* → *Create new runner*. + +```bash +mkdir -p /opt/gitea-runner && cd /opt/gitea-runner + +docker run -d --name gitea-runner --restart unless-stopped \ + -e GITEA_INSTANCE_URL=https://git.elycon.solutions \ + -e GITEA_RUNNER_REGISTRATION_TOKEN= \ + -e GITEA_RUNNER_NAME=alpenwerk-deploy \ + -e GITEA_RUNNER_LABELS=self-hosted,ubuntu-latest \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /opt/gitea-runner:/data \ + gitea/act_runner:latest +``` + +Zu den Labels: `self-hosted` verlangt der Deploy-Workflow, `ubuntu-latest` die +bestehende `ci.yml`. Fehlt eines, bleibt der jeweilige Lauf ohne Fehlermeldung +in der Warteschlange — **das ist der häufigste Grund, warum „nichts passiert"**. + +> Der eingehängte Docker-Socket gibt dem Runner faktisch Wurzelrechte auf dem +> Host. Das ist bei einem Deploy-Runner der Zweck der Übung, aber es heisst +> auch: wer in dieses Repository schreiben darf, darf auf diesem Server alles. +> Bei zwei Personen vertretbar; bei einem grösseren Kreis gehört der Runner auf +> eine eigene Maschine, die sich per SSH verbindet. + +### b) Secrets hinterlegen + +Gitea → Repository → *Settings* → *Actions* → *Secrets*. Dieselben Werte wie in +[Abschnitt 1](#1-env-anlegen); der Workflow baut daraus zur Laufzeit die `.env` +und löscht sie danach wieder. + +| Secret | Anmerkung | +|---|---| +| `DATABASE_URL` | Transaktions-Pooler (Supabase: 6543) — den benutzt die Anwendung | +| `SUPABASE_DB_URL` | **Direkter** Zugang (Supabase: 5432) — nur für die Migrationen | +| `AUTH_SECRET` | | +| `AUTH_URL` | z. B. `https://hr.elycon.solutions` | +| `AUTH_MICROSOFT_ENTRA_ID_ID` | | +| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | | +| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | | +| `CRON_SECRET` | | + +Warum zwei Verbindungsstrings: der Transaktions-Pooler bricht bei einer +Migration ab, die mehrere Anweisungen in einer Transaktion bündelt. Migrationen +gehören über die direkte Verbindung, der laufende Betrieb über den Pooler. + +Zusätzlich unter *Variables* (kein Secret, nur eine Einstellung): + +| Variable | Vorgabe | Wofür | +|---|---|---| +| `COMPOSE_PROJECT_NAME` | `alpenwerk-hr` | Muss zum bestehenden Stapel passen — sonst entsteht ein zweiter daneben | + +Den bestehenden Namen zeigt auf dem Server: + +```bash +docker compose ls +``` + +### c) Was der Lauf tut + +1. `.env` aus den Secrets schreiben (fehlt eines, bricht er mit Namen ab). +2. **Migrationen anwenden** — erst `--dry-run` fürs Protokoll, dann echt. +3. `docker compose build` und `up -d` gegen den Docker-Dienst des Hosts. +4. Auf `healthy` warten (bis zu zwei Minuten). Wird die Anwendung nicht gesund, + schlägt der Lauf fehl und hängt die letzten 80 Logzeilen an. +5. `.env` wieder löschen. + +Der Workflow ist **nicht** an `ci.yml` gekoppelt: ein Push auf `master` rollt +aus, auch wenn die Tests parallel noch laufen. Soll erst nach grünen Tests +ausgerollt werden, gehört der `deploy`-Job mit `needs: [check]` in `ci.yml` — +dann allerdings braucht der Runner auch das Label, mit dem `check` läuft. + +## 5a. Migrationen + +Eingespielt werden sie von [`scripts/migrate.mjs`](scripts/migrate.mjs) — +demselben Läufer, den auch der Deploy benutzt: + +```bash +node --env-file=.env scripts/migrate.mjs --dry-run # was stünde an +node --env-file=.env scripts/migrate.mjs # anwenden +``` + +Buch geführt wird in `supabase_migrations.schema_migrations`, derselben Tabelle +in derselben Form, die die Supabase-CLI benutzt — `supabase db push` von der +Arbeitsstation bleibt damit möglich und überspringt, was hier schon lief. + +> **Einmalig, im August 2026 bereits erledigt:** Die Migrationen wurden bis +> dahin von Hand eingespielt, die Buchführungstabelle existierte gar nicht. Ein +> automatischer Lauf hätte deshalb alle 65 Dateien erneut gegen die +> produktive Datenbank gespielt — inklusive `initial_schema` und der +> OM-Umstellung. Vor dem Einschalten wurde einmal +> `node scripts/migrate.mjs --baseline` gefahren: das verbucht alles Vorhandene +> als angewendet, **ohne es auszuführen**. Wer eine weitere Umgebung aufsetzt, +> deren Datenbank schon steht, braucht denselben Schritt. + +## 5b. Updates von Hand ausrollen + +Falls der Runner nicht läuft oder ein Stand ausser der Reihe gebraucht wird: ```bash git pull +node --env-file=.env scripts/migrate.mjs docker compose build docker compose up -d ``` @@ -231,13 +347,8 @@ Kurzer Downtime-Moment beim Neustart des `app`-Containers ist bei dieser Single-Instance-Compose-Konfiguration normal. Für Zero-Downtime-Deployments wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6). -Datenbank-Migrationen (`supabase/migrations/*.sql`) werden weiterhin über -die Supabase CLI gegen das Supabase-Projekt gefahren, unabhängig vom -App-Deployment: - -```bash -supabase db push -``` +Der Migrationsschritt steht bewusst **vor** dem Neubau: der neue Code erwartet +das neue Schema, und umgekehrt liefe die neue Anwendung kurz gegen das alte. ## 6. Hinweis bei mehreren Replicas @@ -257,6 +368,23 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig. ## Troubleshooting +- **Push gemacht, aber kein Lauf startet:** meist die Labels. Der Lauf steht + dann in Gitea unter *Actions* als „Waiting" — ohne Fehlermeldung, weil kein + Runner das verlangte Label anbietet. `deploy.yml` verlangt `self-hosted`, + `ci.yml` verlangt `ubuntu-latest`. Welche Labels registriert sind, zeigt + Gitea → *Settings* → *Actions* → *Runners*; ändern lassen sie sich durch + erneutes Registrieren. +- **Deploy läuft, aber es entsteht ein zweiter Stapel:** `COMPOSE_PROJECT_NAME` + passt nicht zum bestehenden. Auf dem Server `docker compose ls` — der dort + gelistete Name gehört als Variable ins Repository. Bis dahin läuft die alte + Instanz weiter und beansprucht Port 3000; die neue scheitert daran. +- **`Cannot connect to the Docker daemon` im Deploy:** dem Runner-Container + fehlt der Socket. Er braucht `-v /var/run/docker.sock:/var/run/docker.sock`. +- **Migration schlägt im Deploy fehl:** der Lauf bricht ab, bevor Container + angefasst werden — die alte Version läuft also weiter. Die fehlgeschlagene + Datei wurde vollständig zurückgerollt und **nicht** verbucht; nach der + Korrektur reicht ein erneuter Push. + - **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der Entra-Registrierung muss exakt `https:///api/auth/callback/microsoft-entra-id` lauten. Steht die diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..a4620d1 --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +// Spielt ausstehende Migrationen ein — und führt Buch darüber. +// +// ═══ Warum ein eigener Läufer und nicht `supabase db push` ═══ +// +// Zwei Gründe, beide praktisch: +// +// 1. **Er braucht nur `pg`.** Das Paket liegt ohnehin im Projekt. Die +// Supabase-CLI müsste im Runner-Container bei jedem Lauf heruntergeladen +// werden, und ihre Version driftet unabhängig von diesem Repository. +// 2. **Er tut genau eine Sache.** `db push` vergleicht Schemata, erzeugt +// Diffs und kann bei einer Abweichung mehr tun als nur die fehlenden +// Dateien anzuwenden. Beim automatischen Ausrollen ist „nur die fehlenden +// Dateien, in Reihenfolge, jede in ihrer eigenen Transaktion" genau das +// gewünschte Verhalten und nichts darüber hinaus. +// +// Die Buchführung liegt bewusst in `supabase_migrations.schema_migrations` — +// derselben Tabelle in derselben Form, die die CLI benutzt. Damit bleibt +// `supabase db push` weiterhin möglich, etwa von der Arbeitsstation aus: es +// sieht dieselben Einträge und überspringt, was hier schon lief. +// +// ═══ Aufrufe ═══ +// +// node scripts/migrate.mjs ausstehende anwenden +// node scripts/migrate.mjs --dry-run nur zeigen, was anstünde +// node scripts/migrate.mjs --baseline alles als angewendet verbuchen, +// **ohne** es auszuführen +// +// `--baseline` ist für den einen Fall gedacht, in dem eine Datenbank schon auf +// dem Stand ist, die Buchführung aber fehlt — genau die Lage dieses Projekts +// im August 2026, weil die Migrationen bis dahin von Hand eingespielt wurden. +// Ohne diesen Schritt hielte der Läufer alle 65 für ausstehend und würde sie +// gegen eine bereits migrierte Datenbank laufen lassen. + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import pg from "pg"; + +const VERZEICHNIS = "supabase/migrations"; + +const argumente = new Set(process.argv.slice(2)); +const nurZeigen = argumente.has("--dry-run"); +const baseline = argumente.has("--baseline"); + +// SUPABASE_DB_URL ist der direkte Zugang (Port 5432, Sitzungsmodus). +// DATABASE_URL zeigt in dieser Anwendung auf den Transaktions-Pooler (6543), +// und der verträgt kein `create schema` in einer Transaktion mit mehreren +// Anweisungen — Migrationen gehören deshalb über die direkte Verbindung. +const verbindung = process.env.SUPABASE_DB_URL || process.env.MIGRATE_DATABASE_URL; +if (!verbindung) { + console.error( + "SUPABASE_DB_URL fehlt. Erwartet wird der **direkte** Postgres-Zugang (bei Supabase Port 5432),\n" + + "nicht der Transaktions-Pooler aus DATABASE_URL." + ); + process.exit(1); +} + +/** Dateiname → { version, name }; „20260818100000_offboarding.sql". */ +function zerlegen(datei) { + const punkt = datei.indexOf("_"); + return punkt === -1 + ? { version: datei.replace(/\.sql$/, ""), name: "" } + : { version: datei.slice(0, punkt), name: datei.slice(punkt + 1).replace(/\.sql$/, "") }; +} + +const dateien = readdirSync(VERZEICHNIS) + .filter((f) => f.endsWith(".sql")) + .sort(); + +const client = new pg.Client({ + connectionString: verbindung, + ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, +}); +await client.connect(); + +try { + // Dieselbe Form, die die Supabase-CLI anlegt und erwartet. + await client.query(`create schema if not exists supabase_migrations`); + await client.query(` + create table if not exists supabase_migrations.schema_migrations ( + version text primary key, + statements text[], + name text + )`); + + const verbucht = new Set( + (await client.query(`select version from supabase_migrations.schema_migrations`)).rows.map((r) => r.version) + ); + + const ausstehend = dateien.filter((f) => !verbucht.has(zerlegen(f).version)); + + if (ausstehend.length === 0) { + console.log(`Nichts anzuwenden — ${verbucht.size} Migrationen verbucht, ${dateien.length} Dateien vorhanden.`); + process.exit(0); + } + + if (nurZeigen) { + console.log(`${ausstehend.length} ausstehend:`); + for (const f of ausstehend) console.log(" " + f); + process.exit(0); + } + + if (baseline) { + // Nur verbuchen. Der Inhalt wird trotzdem mitgeschrieben, damit später + // nachvollziehbar ist, welcher Text als angewendet galt. + for (const datei of ausstehend) { + const { version, name } = zerlegen(datei); + const inhalt = readFileSync(join(VERZEICHNIS, datei), "utf8"); + await client.query( + `insert into supabase_migrations.schema_migrations (version, statements, name) + values ($1, $2, $3) on conflict (version) do nothing`, + [version, [inhalt], name] + ); + } + console.log(`${ausstehend.length} Migrationen als angewendet verbucht, ohne sie auszuführen.`); + process.exit(0); + } + + console.log(`${ausstehend.length} ausstehend, werden angewendet:`); + for (const datei of ausstehend) { + const { version, name } = zerlegen(datei); + const inhalt = readFileSync(join(VERZEICHNIS, datei), "utf8"); + const start = Date.now(); + + // Jede Datei in ihrer eigenen Transaktion: eine, die scheitert, hinterlässt + // nichts Halbes, und die davor bleiben angewendet und verbucht. Der Lauf + // bricht danach ab — weiterzumachen hiesse, auf einem Stand aufzubauen, + // den es nicht gibt. + await client.query("begin"); + try { + await client.query(inhalt); + await client.query( + `insert into supabase_migrations.schema_migrations (version, statements, name) values ($1, $2, $3)`, + [version, [inhalt], name] + ); + await client.query("commit"); + console.log(` ok ${datei} (${Date.now() - start} ms)`); + } catch (fehler) { + await client.query("rollback"); + console.error(` FEHLGESCHLAGEN ${datei}`); + console.error(" " + (fehler instanceof Error ? fehler.message : String(fehler))); + process.exit(1); + } + } + console.log("Fertig."); +} finally { + await client.end(); +}