The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
379 lines
16 KiB
Markdown
379 lines
16 KiB
Markdown
# Deployment
|
||
|
||
Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server,
|
||
zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt
|
||
keine Werte, die beim Bauen eingebacken werden; alles kommt zur Laufzeit
|
||
aus `.env`.
|
||
|
||
Zu klären, bevor es losgeht: die Umgebungsvariablen aus
|
||
[Abschnitt 1](#1-env-anlegen), die Umleitungs-URI in der
|
||
Entra-Registrierung, und dass eine neue Person nach ihrer ersten Anmeldung
|
||
eine `profiles`-Zeile braucht (siehe [docs/entra-sso.md](docs/entra-sso.md)).
|
||
|
||
## Was wird containerisiert – und was nicht
|
||
|
||
- **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank**
|
||
(Dienst `db`, `postgres:17-alpine`). Beide zusammen in
|
||
`docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`.
|
||
- **Die Datenbank gehört zum Projekt.** Was eine gehostete Plattform sonst
|
||
beisteuert, bringt `deploy/db-init/` mit: die Anwendungsrolle ohne
|
||
BYPASSRLS, die zwei benutzten Erweiterungen und eine Attrappe des
|
||
`auth`-Schemas, die nur die Migrationen von Juni 2026 brauchen.
|
||
- Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech),
|
||
genügt es, `DATABASE_URL` dorthin zeigen zu lassen und den `db`-Dienst nicht
|
||
zu starten. Die Anwendung merkt keinen Unterschied.
|
||
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
|
||
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
|
||
keinen Anmeldedienst, der mit ausgerollt werden müsste.
|
||
- **Der nächtliche Lauf** ist ein eigener Container (`cron`): täglich 03:00
|
||
Uhr ruft er `/api/cron/apply-pending-changes` auf, um fällige Versetzungen,
|
||
Beförderungen, Karenz- und Reorg-Änderungen zu übernehmen. Ausgewiesen wird
|
||
der Aufruf über `CRON_SECRET` — es gibt dabei keine angemeldete Person.
|
||
|
||
## Betrieb im Firmennetz — zwei Dinge vorab
|
||
|
||
Die App läuft intern, aber sie ist **nicht** von der Aussenwelt unabhängig.
|
||
Beides vor der Installation klären, sonst scheitert es am Ende an der
|
||
Firewall:
|
||
|
||
**1. Der Server braucht ausgehenden Zugang.** Eingehend aus dem Internet
|
||
nichts, ausgehend zwingend:
|
||
|
||
| Ziel | Wofür | Ohne das |
|
||
|---|---|---|
|
||
| `login.microsoftonline.com` (443) | Auth.js tauscht den Anmeldecode **serverseitig** gegen ein Token und lädt die Konfiguration des Ausstellers | keine Anmeldung möglich |
|
||
|
||
Dass die Anmeldung im Browser der Person stattfindet, genügt **nicht** — der
|
||
Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem
|
||
abgeschotteten Netz, braucht es dafür einen Proxy.
|
||
|
||
Die Datenbank taucht hier nicht mehr auf: sie läuft als Container daneben, im
|
||
selben Compose-Netz. Nach aussen geht dafür nichts.
|
||
|
||
**2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für
|
||
`localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein
|
||
**öffentlicher DNS-Name, der auf die private Adresse zeigt** (z. B.
|
||
`hr.elycon.solutions` → `10.x.x.x`) und ein Zertifikat über die
|
||
DNS-Challenge. Das ist zulässig, verbreitet, und liefert ein regulär
|
||
vertrauenswürdiges Zertifikat, ohne dass der Server je aus dem Internet
|
||
erreichbar ist.
|
||
|
||
Beispielkonfiguration: [`deploy/Caddyfile`](deploy/Caddyfile).
|
||
|
||
Die Alternative — selbst signiertes Zertifikat — bedeutet, es auf jedem
|
||
Arbeitsplatz als vertrauenswürdig zu hinterlegen. Bei zwei Personen machbar,
|
||
bei zwanzig nicht.
|
||
|
||
## Voraussetzungen
|
||
|
||
- Docker + Docker Compose (v2, das in Docker Engine enthaltene
|
||
`docker compose`) auf dem Zielserver. Sonst nichts — weder Node noch psql;
|
||
was gebraucht wird, kommt aus Containern.
|
||
|
||
## 1. `.env` anlegen
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
Werte eintragen:
|
||
|
||
| Variable | Woher |
|
||
|---|---|
|
||
| `POSTGRES_PASSWORD` | selbst erzeugen: `openssl rand -base64 24` — das des Verwalters |
|
||
| `APP_DB_PASSWORD` | selbst erzeugen — das der Anwendungsrolle `alpenwerk_app` |
|
||
| `DATABASE_URL` | `postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk`. Die Rolle darf **kein** `BYPASSRLS` haben — `deploy/db-init` legt sie genau so an |
|
||
| `DATABASE_SSL` | `false` im Compose-Netz; die Verbindung verlässt den Server nicht |
|
||
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
|
||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
|
||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
|
||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<verzeichnis-id>/v2.0` |
|
||
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
||
|
||
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
|
||
|
||
Wichtig zum Verständnis:
|
||
|
||
- **Nichts davon wird in das Image eingebacken.** Es gibt keine
|
||
`NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit
|
||
über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen
|
||
neuen Build — und dasselbe Image läuft in Test und Produktion.
|
||
- `.env` steht schon in `.gitignore` – nicht committen.
|
||
|
||
## 2. Bauen und lokal testen
|
||
|
||
```bash
|
||
docker compose build
|
||
docker compose up -d
|
||
docker compose logs -f app
|
||
```
|
||
|
||
App ist danach unter `http://localhost:3000` erreichbar. Healthcheck prüft
|
||
`GET /login`; Status siehe `docker compose ps`.
|
||
|
||
Cron-Sidecar prüfen:
|
||
|
||
```bash
|
||
docker compose logs -f cron
|
||
```
|
||
|
||
## 3. Auf einem Server deployen
|
||
|
||
Einfachste Variante – Repo direkt auf dem Server bauen:
|
||
|
||
```bash
|
||
git clone <repo-url> && cd manner-app
|
||
cp .env.example .env # Werte eintragen
|
||
docker compose up -d --build
|
||
```
|
||
|
||
Alternative für CI/CD (Image einmal bauen, überall pullen): Image in einer
|
||
Registry bauen und pushen, auf dem Server nur
|
||
`docker compose pull && docker compose up -d` ausführen. Dafür in
|
||
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen.
|
||
|
||
Build-Argumente braucht es dabei **keine**: das Abbild enthält keine
|
||
umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus `.env`.
|
||
Dasselbe Abbild läuft damit in Test und Produktion.
|
||
|
||
### Bei einer leeren Datenbank
|
||
|
||
Der `db`-Container legt beim ersten Start Rollen und Erweiterungen an; das
|
||
Schema kommt danach aus den Migrationen:
|
||
|
||
```bash
|
||
docker compose up -d db
|
||
docker compose run --rm migrate # legt Tabellen, Funktionen, Policies an
|
||
docker compose up -d --build
|
||
```
|
||
|
||
`migrate` steht im Profil `tools` und läuft bei `docker compose up` nicht mit.
|
||
Auf dem Host wird dafür weder Node noch psql gebraucht — nur Docker.
|
||
|
||
### Was am `auth`-Schema übrigbleibt
|
||
|
||
`deploy/db-init/01-auth-attrappe.sql` legt ein leeres `auth`-Schema an. Es wird
|
||
im Betrieb **nicht** gebraucht: kein Fremdschlüssel, keine Policy, keine
|
||
Spaltenvorgabe verweist darauf (geprüft). Gebraucht wird es nur beim Abspielen
|
||
der Migrationen von Juni 2026, die damals noch an `auth.users` hingen. Die
|
||
Alternative wäre, jene Dateien umzuschreiben — also Geschichte zu fälschen: sie
|
||
beschreiben, was damals galt.
|
||
|
||
## 4. Reverse Proxy + HTTPS
|
||
|
||
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
|
||
werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
|
||
Validierung.
|
||
|
||
Fertige Konfiguration: [`deploy/Caddyfile`](deploy/Caddyfile) — mit
|
||
DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe
|
||
[oben](#betrieb-im-firmennetz--zwei-dinge-vorab)).
|
||
|
||
```bash
|
||
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
|
||
sudo systemctl reload caddy
|
||
```
|
||
|
||
`docker-compose.yml` veröffentlicht Port 3000 bewusst nur auf
|
||
`127.0.0.1` — die App ist also ausschliesslich über den Proxy erreichbar,
|
||
nicht daneben unverschlüsselt.
|
||
|
||
Zusätzlich in die `.env`:
|
||
|
||
```
|
||
AUTH_URL=https://hr.elycon.solutions
|
||
```
|
||
|
||
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. 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) Der Runner
|
||
|
||
Verwendet wird der bestehende, globale Runner **`elycon-runner-01`** (act_runner
|
||
v0.2.11, Labels `docker` und `ubuntu-latest`). Ein eigener ist nicht nötig.
|
||
|
||
Beide Workflows laufen deshalb auf `ubuntu-latest`. **Ein Label, das kein
|
||
Runner führt, lässt den Lauf wortlos in der Warteschlange stehen** — kein
|
||
Fehler, keine Meldung, nur „Waiting". Das ist der häufigste Grund, warum
|
||
scheinbar nichts passiert.
|
||
|
||
#### Der Job läuft im Container, nicht auf dem Server
|
||
|
||
Das ist die Stelle, an der es klemmen kann. act_runner startet je Job einen
|
||
eigenen Container; der Docker-Socket des **Hosts** ist darin nur, wenn der
|
||
Runner so konfiguriert ist:
|
||
|
||
```yaml
|
||
# config.yaml des Runners
|
||
container:
|
||
options: -v /var/run/docker.sock:/var/run/docker.sock
|
||
valid_volumes:
|
||
- /var/run/docker.sock
|
||
```
|
||
|
||
Zusätzlich muss das Job-Abbild die Docker-CLI mitbringen (die üblichen
|
||
`runner-images`/`catthehacker`-Abbilder tun das).
|
||
|
||
Der erste Schritt des Deploy-Workflows prüft genau das und bricht mit einer
|
||
Anleitung ab, wenn etwas fehlt — statt drei Schritte später an einer Meldung zu
|
||
scheitern, mit der niemand etwas anfangen kann.
|
||
|
||
> Der durchgereichte Socket gibt dem Job faktisch Wurzelrechte auf dem Host.
|
||
> Bei einem Deploy-Runner ist das der Zweck der Übung, aber es heisst auch: wer
|
||
> in dieses Repository schreiben darf, darf auf diesem Server alles. Bei einem
|
||
> **globalen** Runner gilt das für jedes Repository der Instanz, das denselben
|
||
> Socket benutzt. Ist das zu weit gefasst, ist der Weg über SSH der richtige:
|
||
> ein Schlüssel als Repository-Secret, der nur auf den einen Server und nur auf
|
||
> das Deploy-Verzeichnis zeigt.
|
||
|
||
### 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` | Verbindung der Anwendungsrolle — die benutzt die Anwendung |
|
||
| `MIGRATE_DATABASE_URL` | Zugang als Verwalter — 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 `migrationen.schema_migrations`: eine Zeile je
|
||
angewendeter Datei, mit ihrem Text. Der Läufer wendet nur an, was dort
|
||
fehlt.
|
||
|
||
> **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
|
||
```
|
||
|
||
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).
|
||
|
||
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
|
||
|
||
Läuft die App skaliert (mehrere `app`-Container hinter einem Load
|
||
Balancer), muss `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` explizit gesetzt und
|
||
auf allen Instanzen identisch sein – sonst schlagen Server Actions
|
||
(`actions/*.ts`, z. B. Mitarbeiter- und Positions-Mutationen) mit "Failed to
|
||
find Server Action" fehl, wenn eine Anfrage auf einer anderen Instanz landet
|
||
als der, die das Formular gerendert hat. Erzeugen mit:
|
||
|
||
```bash
|
||
openssl rand -base64 32
|
||
```
|
||
|
||
Als zusätzliche Env-Variable in `.env` eintragen. Bei der aktuellen
|
||
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. Beide Workflows verlangen
|
||
`ubuntu-latest`; `elycon-runner-01` führt es. Welche Labels registriert sind,
|
||
zeigt Gitea → *Site Administration* → *Actions* → *Runners*.
|
||
- **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://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
|
||
Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie
|
||
selbst sieht, zusätzlich `AUTH_URL` setzen.
|
||
- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die
|
||
Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine
|
||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben
|
||
Kennung, die in `app_users` steht.
|
||
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
||
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
||
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
||
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
|
||
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
|
||
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im
|
||
Start-Log.
|