docs/ war leer. Jetzt drei Dateien, jede Behauptung am Code verifiziert: * deployment.md — docker compose, Migrationsweg, vollständige Env-Referenz mit den Fallstricken (P-256-taugliches PDS_JWT_SECRET, Pflicht-aber-tot S3_BUCKET_APPVIEW), Release-Build, systemd-Units, Reverse-Proxy inkl. des Hinweises, dass die AppView CORS Any liefert und der Proxy den Header ersetzen statt ergänzen muss, Health-Checks und Cursor-Verhalten beim Neustart. * architecture.md — Crate-Verantwortlichkeiten, ASCII-Datenfluss, beide DB-Schemata. Hält fest, was die Topologie erklärt: die eigene PDS speist keinen Firehose, eigene Records erreichen die AppView nur über den Best-Effort-Push. * tauri-release.md — Signing-Keys, v2-Updater-Config, latest.json, Build pro Plattform. Der _comment in tauri.conf.json war irreführend: active/dialog sind v1-Reste, die der v2-Updater ignoriert; dass nichts passiert, liegt daran, dass niemand check() aufruft und das Plugin nicht installiert ist. README bekommt Phase 8, eine Doku-Übersicht, korrigierte Testanleitung (src-tauri ist ein eigener Workspace und wird von cargo test --workspace nicht erfasst) und einen Abschnitt "Bekannte Lücken" statt der bisher lückenlosen Erfolgsmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
529 lines
22 KiB
Markdown
529 lines
22 KiB
Markdown
# Deployment
|
|
|
|
Wie der Stack real betrieben wird: zwei Rust-Binaries (`pds-server`, `appview`),
|
|
zwei Postgres-Datenbanken und ein MinIO-Bucket-Paar. Der Tauri-Client wird
|
|
separat gebaut (siehe [tauri-release.md](tauri-release.md)), der Aufbau der
|
|
Komponenten steht in [architecture.md](architecture.md).
|
|
|
|
## Voraussetzungen
|
|
|
|
| Komponente | Version | Woher belegt |
|
|
|---|---|---|
|
|
| Rust-Toolchain | ≥ 1.80 | `rust-version` in `Cargo.toml` |
|
|
| Docker + Compose | aktuell | `docker-compose.yml` |
|
|
| PostgreSQL | 16 | `postgres:16-alpine` im Compose-File |
|
|
| MinIO | latest | `minio/minio` im Compose-File |
|
|
| Node + npm | Node 20, npm 10.8.2 | `packageManager` in `crates/tauri-app/package.json` — nur für den Desktop-Client nötig |
|
|
|
|
Ein C-Compiler/Linker wird für `rusqlite` (Feature `bundled`) und die
|
|
Crypto-Crates gebraucht.
|
|
|
|
## 1. Infrastruktur starten
|
|
|
|
```bash
|
|
docker compose up -d
|
|
```
|
|
|
|
Das Compose-File startet vier Container:
|
|
|
|
| Container | Dienst | Host-Port | Zugangsdaten (Default) |
|
|
|---|---|---|---|
|
|
| `maarcadetweet-pds-db` | Postgres für die PDS | `5434` → 5432 | `pds` / `pds`, DB `pds` |
|
|
| `maarcadetweet-appview-db` | Postgres für die AppView | `5435` → 5432 | `appview` / `appview`, DB `appview` |
|
|
| `maarcadetweet-minio` | S3-kompatibler Blob-Store | `9100` (API), `9101` (Konsole) | `minioadmin` / `minioadmin` |
|
|
| `maarcadetweet-minio-init` | Einmal-Job | — | legt die Buckets an und beendet sich |
|
|
|
|
`minio-init` legt `maarcadetweet-pds` und `maarcadetweet-appview` an und setzt
|
|
beide auf `anonymous download`. Das ist kein Zufall: `at-blob` liest Blobs über
|
|
ein einfaches `GET ${endpoint}/${bucket}/${key}` ohne Signatur.
|
|
|
|
Alle drei Langläufer haben Healthchecks (`pg_isready` bzw.
|
|
`/minio/health/live`), Zustand prüfen mit `docker compose ps`.
|
|
|
|
**Für Produktion ändern:** Die Passwörter im Compose-File sind Dev-Defaults und
|
|
stehen im Klartext. Wer den Stack öffentlich betreibt, ersetzt sie (und die
|
|
korrespondierenden Werte in `.env`) oder betreibt Postgres/MinIO ganz außerhalb
|
|
von Compose. Die Ports 5434/5435/9100/9101 sind auf allen Interfaces gemappt —
|
|
in Produktion auf `127.0.0.1:` binden.
|
|
|
|
## 2. Migrationen
|
|
|
|
**Migrationen laufen automatisch beim Start der Binaries.** Es gibt keinen
|
|
separaten Migrationsschritt und kein Skript in `scripts/` (das Verzeichnis ist
|
|
leer).
|
|
|
|
* `crates/pds-server/src/main.rs`: `sqlx::migrate!("../../migrations/pds").run(&db)`
|
|
* `crates/appview/src/main.rs`: `sqlx::migrate!("../../migrations/appview").run(&db)`
|
|
|
|
`sqlx::migrate!` ist ein Makro: die `.sql`-Dateien werden **zur Compile-Zeit in
|
|
das Binary eingebettet**. Konsequenzen für den Betrieb:
|
|
|
|
* Eine neue Migration wird erst nach einem Rebuild wirksam — das Binary allein
|
|
neu zu starten reicht nicht.
|
|
* Der Migrationsstand liegt in der Tabelle `_sqlx_migrations` der jeweiligen DB.
|
|
Wird eine bereits angewendete Datei nachträglich geändert, bricht der Start
|
|
mit einem Checksum-Fehler ab.
|
|
* Die Datenbanken müssen die Extensions `uuid-ossp`, `pgcrypto` (PDS) bzw.
|
|
`uuid-ossp`, `pg_trgm` (AppView) anlegen dürfen (`CREATE EXTENSION` in
|
|
`0001_init.sql`). Beim offiziellen Postgres-Image ist der Compose-User
|
|
Superuser; bei managed Postgres müssen die Extensions vorab freigeschaltet
|
|
sein.
|
|
* `migrations/appview/0001_init.sql` legt die Cursor-Zeile an
|
|
(`INSERT INTO jetstream_cursor (id, cursor) VALUES (1, 0)`) — ohne sie fällt
|
|
die AppView auf Cursor `0` zurück und startet ohne Resume.
|
|
|
|
Maßgeblich ist immer der Inhalt von `migrations/pds/` bzw.
|
|
`migrations/appview/` — beim Schreiben dieses Dokuments `0001_init` und
|
|
`0002_blob_mime` auf der PDS-Seite, `0001_init` bis `0008_notifications` auf
|
|
der AppView-Seite.
|
|
|
|
Die Dateinamen folgen dem `<version>_<name>.sql`-Schema von sqlx, ein manueller
|
|
Lauf mit `sqlx-cli` (`sqlx migrate run --source migrations/appview`) ist damit
|
|
prinzipiell kompatibel. `sqlx-cli` ist aber **nicht** Teil des Workspaces und
|
|
wird nirgends im Code oder in CI verwendet — der dokumentierte Weg bleibt der
|
|
automatische Lauf beim Start.
|
|
|
|
Im Code gibt es keine `sqlx::query!`-Makros (nur die Laufzeit-Varianten
|
|
`sqlx::query` / `query_as` / `query_scalar`). Für den Build wird also **keine**
|
|
erreichbare Datenbank und kein `.sqlx`-Offline-Cache gebraucht.
|
|
|
|
## 3. Umgebungsvariablen
|
|
|
|
`cp .env.example .env` und anpassen — das reicht für den Dev-Betrieb. Beide
|
|
Binaries rufen als erstes in `main()` `dotenvy::dotenv()` auf (`dotenvy` ist
|
|
Workspace-Dependency und in `crates/pds-server` wie `crates/appview`
|
|
eingebunden) und laden damit eine `.env` aus dem Arbeitsverzeichnis bzw. den
|
|
übergeordneten Verzeichnissen. **Echte Umgebungsvariablen gewinnen** über die
|
|
Werte aus der Datei; ist keine `.env` vorhanden, passiert schlicht nichts.
|
|
|
|
Im Serverbetrieb ist die Datei damit optional: systemd (`EnvironmentFile=`)
|
|
oder die Container-Runtime setzen die Variablen direkt, und sie überschreiben
|
|
eine versehentlich mitdeployte `.env`.
|
|
|
|
### Pflicht (fehlt eine, bricht `AppConfig::from_env()` mit `missing env: X` ab)
|
|
|
|
Quelle: `crates/at-shared/src/config.rs`. Beide Binaries lesen dieselbe
|
|
`AppConfig`, also braucht **auch die AppView die PDS-Variablen** und umgekehrt.
|
|
|
|
| Variable | Bedeutung |
|
|
|---|---|
|
|
| `PDS_HOST` | Listen-Adresse der PDS (`format!("{host}:{port}")` → `TcpListener::bind`) |
|
|
| `PDS_PORT` | Listen-Port der PDS, `u16` |
|
|
| `PDS_PUBLIC_URL` | Öffentliche Basis-URL der PDS. Geht in den DID-Doc-`serviceEndpoint` (`routes/auth.rs`), in den JWT-`iss` (`jwt_issuer.rs`), als `public_base` in den Blob-Store und als Fallback für `PDS_INTERNAL_URL` |
|
|
| `PDS_HANDLE_DNS_ZONE` | Handle-Suffix inkl. führendem Punkt, z. B. `.maarcadetweet.local`. Wird in `com.atproto.identity.resolveHandle` beim Suffix-Matching benutzt und (ohne Punkt) als `availableUserDomains` in `describeServer` gemeldet |
|
|
| `PDS_JWT_SECRET` | **Hex-String** (≥ 32 Bytes, gültiger P-256-Skalar), aus dem der Serverschlüssel für die Access-/Refresh-JWTs abgeleitet wird — siehe Fallstricke unten |
|
|
| `APPVIEW_HOST` | Listen-Adresse der AppView |
|
|
| `APPVIEW_PORT` | Listen-Port der AppView, `u16` |
|
|
| `APPVIEW_PUBLIC_URL` | Öffentliche Basis-URL der AppView; die PDS benutzt sie als Fallback-Ziel für den Ingest-Push |
|
|
| `JETSTREAM_URL` | WebSocket-URL des Jetstream-Relays |
|
|
| `JETSTREAM_COLLECTIONS` | Komma-separierte Liste der Collections im `subscribe`-Filter. Leere Einträge werden verworfen |
|
|
| `DATABASE_URL_PDS` | Postgres-DSN der PDS-DB |
|
|
| `DATABASE_URL_APPVIEW` | Postgres-DSN der AppView-DB |
|
|
| `S3_ENDPOINT` | Basis-URL des Blob-Stores (MinIO) |
|
|
| `S3_REGION` | Region-String; wird im aktuellen `at-blob` nur mitgeführt, nicht signiert |
|
|
| `S3_ACCESS_KEY` | MinIO-Access-Key |
|
|
| `S3_SECRET_KEY` | MinIO-Secret-Key |
|
|
| `S3_BUCKET_PDS` | Bucket, in den die PDS Blobs schreibt |
|
|
| `S3_BUCKET_APPVIEW` | Bucket für die AppView — Pflichtfeld in der Config, wird aber von keinem Code-Pfad gelesen |
|
|
| `PLC_DIRECTORY_URL` | PLC-Directory. Die PDS submittet dort neue `did:plc:`-Ops (best effort), die AppView löst darüber Handles auf |
|
|
|
|
### Optional
|
|
|
|
| Variable | Default | Bedeutung |
|
|
|---|---|---|
|
|
| `PDS_INTERNAL_URL` | `PDS_PUBLIC_URL` | Cluster-interne PDS-URL, die die AppView für ihren `PdsHandleResolver` nutzt |
|
|
| `APPVIEW_INTERNAL_URL` | `APPVIEW_PUBLIC_URL` | Cluster-interne AppView-URL für den Commit-Push der PDS (`crates/pds-server/src/state.rs`) |
|
|
| `APPVIEW_INGEST_SECRET` | unset | Shared Secret für `POST /internal/ingest-commit`. Unset = Dev-Modus, der Endpoint akzeptiert anonyme Requests. Gesetzt = Header `X-Ingest-Secret` muss (konstantzeitverglichen) passen. Muss auf **beiden** Seiten identisch gesetzt sein |
|
|
| `APPVIEW_HANDLE_SYNC_INTERVAL_SECS` | `300` | Intervall des Handle-Sync-Workers. Nicht parsbare Werte fallen still auf den Default zurück |
|
|
| `RUST_LOG` | `info` | `tracing_subscriber::EnvFilter::try_from_default_env()` in beiden Binaries und im Tauri-Client |
|
|
|
|
### Nur für den Desktop-Client
|
|
|
|
Gelesen in `crates/tauri-app/src-tauri/src/lib.rs`:
|
|
|
|
| Variable | Default |
|
|
|---|---|
|
|
| `MAARCADETWEET_PDS_URL` | `http://127.0.0.1:2583` |
|
|
| `MAARCADETWEET_APPVIEW_URL` | `http://127.0.0.1:2584` |
|
|
|
|
### Nur für Tests
|
|
|
|
Die Integrationstests in `crates/appview/tests/` und
|
|
`crates/pds-server/tests/` lesen `DATABASE_URL_APPVIEW` bzw. `DATABASE_URL_PDS`
|
|
direkt und überspringen sich selbst, wenn die Variable fehlt.
|
|
|
|
### Fallstricke
|
|
|
|
`.env.example` deckt inzwischen alle Variablen ab, die der Code liest — auch
|
|
die optionalen (`PDS_INTERNAL_URL`, `APPVIEW_INTERNAL_URL`,
|
|
`APPVIEW_HANDLE_SYNC_INTERVAL_SECS`, `APPVIEW_INGEST_SECRET`,
|
|
`MAARCADETWEET_PDS_URL`, `MAARCADETWEET_APPVIEW_URL`), die dort auskommentiert
|
|
mit ihrem Default stehen. Zwei Punkte bleiben trotzdem erklärungsbedürftig:
|
|
|
|
1. **`PDS_JWT_SECRET` ist kein beliebiger String, sondern ein P-256-Skalar.**
|
|
`jwt_issuer.rs::server_p256_keypair` macht
|
|
`hex::decode(secret.trim_start_matches("0x"))`, verlangt ≥ 32 Bytes und
|
|
gibt die ersten 32 Bytes an `p256::SecretKey::from_bytes()`. Der Wert muss
|
|
also **Hex sein und ein gültiger Skalar der P-256-Kurve** — lauter Nullen
|
|
(oder ein Wert ≥ Gruppenordnung) wird abgelehnt.
|
|
|
|
Der Fehler zeigt sich spät und breit: Der Prozess **startet normal**, weil
|
|
der Schlüssel erst beim ersten Zugriff abgeleitet wird. Danach antwortet
|
|
aber alles mit `500`, was `server_p256_public_multibase` bzw.
|
|
`server_p256_keypair` benutzt — nicht nur
|
|
`createAccount` / `createSession` / `refreshSession` (`routes/auth.rs`),
|
|
sondern auch jeder Record-Write, weil dort der Access-Token gegen genau
|
|
diesen Serverschlüssel verifiziert wird: `routes/repo.rs`,
|
|
`routes/feed.rs`, `routes/blob.rs`, `routes/profile.rs`.
|
|
|
|
`.env.example` enthält einen gültigen Zufallswert als Beispiel. Für jede
|
|
echte Instanz einen eigenen erzeugen:
|
|
|
|
```bash
|
|
openssl rand -hex 32
|
|
```
|
|
|
|
2. **`S3_BUCKET_APPVIEW` ist Pflicht, aber ungenutzt.** `AppConfig` verlangt
|
|
die Variable, kein Code-Pfad greift auf `cfg.s3_bucket_appview` zu. Der
|
|
Wert muss trotzdem gesetzt sein, sonst startet keins der Binaries.
|
|
(In `.env.example` ist das entsprechend vermerkt.)
|
|
|
|
Beachtenswert bleibt außerdem `JETSTREAM_COLLECTIONS`: Die Liste in
|
|
`.env.example` enthält jetzt alle Collections, die der Indexer kennt —
|
|
`app.twi.post` (das eigene 160-Zeichen-Lexicon, `POST_COLLECTIONS` in
|
|
`crates/appview/src/indexer.rs`), `app.bsky.feed.post`, `app.bsky.feed.like`,
|
|
`app.bsky.feed.repost`, `app.bsky.graph.follow` und
|
|
`app.bsky.actor.profile`. Wird `app.twi.post` daraus entfernt, tauchen eigene
|
|
Posts nur noch über den direkten PDS→AppView-Push auf, nicht über den
|
|
Firehose-Weg.
|
|
|
|
## 4. Release-Build
|
|
|
|
```bash
|
|
cargo build --release -p pds-server -p appview
|
|
```
|
|
|
|
Ergebnis: `target/release/pds-server` und `target/release/appview`. Das
|
|
Release-Profil im Workspace-`Cargo.toml` setzt `lto = "thin"`,
|
|
`codegen-units = 1`, `opt-level = 3`, `strip = true` — die Binaries sind ohne
|
|
Debug-Symbole, für Crash-Analysen also vorher separat sichern.
|
|
|
|
Da die Migrationen einkompiliert werden, gehören `migrations/` und die
|
|
`lexicons/*.json` (via `include_str!` in `crates/pds-server/src/state.rs`) zum
|
|
Build-Input, nicht zum Laufzeit-Deployment. Auf dem Zielhost muss nur das
|
|
Binary liegen.
|
|
|
|
## 5. systemd
|
|
|
|
Beispiel für einen Host, auf dem die Binaries unter `/opt/maarcadetweet/bin`
|
|
liegen und `/etc/maarcadetweet/env` die Variablen aus Abschnitt 3 enthält
|
|
(Modus `0600`, wegen `PDS_JWT_SECRET` und `APPVIEW_INGEST_SECRET`).
|
|
|
|
`/etc/systemd/system/maarcadetweet-pds.service`:
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=maarcadetweet PDS
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=maarcadetweet
|
|
Group=maarcadetweet
|
|
EnvironmentFile=/etc/maarcadetweet/env
|
|
ExecStart=/opt/maarcadetweet/bin/pds-server
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
# Logs gehen über tracing auf stdout/stderr ins Journal
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
`/etc/systemd/system/maarcadetweet-appview.service`:
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=maarcadetweet AppView
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=maarcadetweet
|
|
Group=maarcadetweet
|
|
EnvironmentFile=/etc/maarcadetweet/env
|
|
ExecStart=/opt/maarcadetweet/bin/appview
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
NoNewPrivileges=true
|
|
PrivateTmp=true
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
```
|
|
|
|
Hinweise:
|
|
|
|
* Beide Units brauchen dieselbe `EnvironmentFile` — beide Binaries bauen die
|
|
vollständige `AppConfig`.
|
|
* `dotenvy::dotenv()` sucht die `.env` ab dem Arbeitsverzeichnis aufwärts.
|
|
Ohne `WorkingDirectory=` ist das unter systemd `/`, es wird also keine
|
|
gefunden — die `EnvironmentFile` ist die einzige Quelle. Läge doch eine
|
|
`.env` im Arbeitsverzeichnis, hätten die systemd-Variablen trotzdem Vorrang.
|
|
* `Type=simple` ist korrekt: keins der Binaries forkt oder meldet Readiness.
|
|
Eine `Type=notify`-Integration existiert nicht.
|
|
* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop`
|
|
beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte
|
|
Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird —
|
|
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 8). Das ist
|
|
unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit
|
|
zeigt und Events idempotent verarbeitet werden.
|
|
* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf
|
|
demselben Host laufen. Die AppView bricht beim Start ab, wenn ihre DB nicht
|
|
erreichbar ist (`PgPool::connect` ohne Retry); die PDS nutzt
|
|
`PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn
|
|
der erste Connect scheitert. `Restart=on-failure` fängt das ab.
|
|
|
|
## 6. Reverse-Proxy
|
|
|
|
### PDS
|
|
|
|
Die PDS muss öffentlich erreichbar sein, damit andere AT-Protocol-Dienste und
|
|
der Client sie ansprechen können. Routen aus `crates/pds-server/src/main.rs`:
|
|
|
|
```
|
|
GET /
|
|
GET /healthz
|
|
GET /xrpc/com.atproto.server.describeServer
|
|
POST /xrpc/com.atproto.server.createAccount
|
|
POST /xrpc/com.atproto.server.createSession
|
|
POST /xrpc/com.atproto.server.refreshSession
|
|
POST /xrpc/com.atproto.identity.resolveHandle
|
|
POST /xrpc/com.atproto.repo.createRecord
|
|
POST /xrpc/com.atproto.repo.deleteRecord
|
|
POST /xrpc/com.atproto.feed.like.create
|
|
POST /xrpc/com.atproto.uploadBlob
|
|
GET /xrpc/com.atproto.sync.getRepo
|
|
GET /xrpc/com.atproto.sync.getBlocks
|
|
GET /xrpc/com.atproto.sync.getLatestCommit
|
|
GET /xrpc/com.atproto.sync.getRecord
|
|
GET /xrpc/com.atproto.sync.listRepos
|
|
GET /xrpc/com.atproto.sync.getBlob
|
|
GET /xrpc/app.bsky.actor.profile.get
|
|
POST /xrpc/app.bsky.actor.profile.set
|
|
GET /blob/:cid
|
|
```
|
|
|
|
nginx-Skizze:
|
|
|
|
```nginx
|
|
server {
|
|
listen 443 ssl http2;
|
|
server_name pds.example.org;
|
|
|
|
# TLS-Konfiguration hier
|
|
|
|
location / {
|
|
proxy_pass http://127.0.0.1:2583;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
|
|
# uploadBlob: MAX_BLOB_SIZE in routes/blob.rs ist 1 MiB.
|
|
# Der Proxy darf nicht enger sein, sonst kommt statt der
|
|
# XRPC-Fehlerantwort ein nginx-413 beim Client an.
|
|
client_max_body_size 1m;
|
|
}
|
|
}
|
|
```
|
|
|
|
`PDS_PUBLIC_URL` muss auf genau diese öffentliche URL zeigen — der Wert landet
|
|
im DID-Doc-`serviceEndpoint`, im JWT-`iss` und als Basis der Blob-URLs.
|
|
|
|
**Noch offen:** Die PDS liefert selbst **kein** `/.well-known/did.json` und
|
|
kein `/.well-known/atproto-did` aus (im Router nicht vorhanden). `at-identity`
|
|
kann solche Dokumente *auflösen*, aber wer `did:web`-Handles auf dieser PDS
|
|
betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern.
|
|
Ebenfalls offen: `describeServer` gibt die DID hart als
|
|
`did:web:pds.maarcadetweet.local` zurück, unabhängig von `PDS_PUBLIC_URL`.
|
|
|
|
### AppView
|
|
|
|
Die AppView setzt ihr CORS selbst — `crates/appview/src/routes.rs`:
|
|
|
|
```rust
|
|
let cors = CorsLayer::new()
|
|
.allow_origin(Any)
|
|
.allow_methods(Any)
|
|
.allow_headers(Any);
|
|
```
|
|
|
|
Also `Access-Control-Allow-Origin: *` für alle Routen, inklusive
|
|
`/internal/ingest-commit`. Der Grund steht im Code: der Tauri-Webview ruft die
|
|
AppView von einem anderen Origin aus auf (`http://127.0.0.1:1430` im Dev,
|
|
`tauri://` / `asset://` im Bundle), und die Read-Endpoints tragen keine
|
|
Auth-Cookies.
|
|
|
|
Für ein öffentliches Deployment heißt das:
|
|
|
|
* `POST /internal/ingest-commit` gehört **nicht** ins Internet. Entweder im
|
|
Proxy blocken (`location /internal/ { deny all; }`) oder die AppView nur auf
|
|
dem internen Interface lauschen lassen und den Public-Vhost nur auf `/api/`
|
|
und `/healthz` mappen.
|
|
* `APPVIEW_INGEST_SECRET` setzen, sobald die AppView irgendwie erreichbar ist —
|
|
ohne das Secret ist der Ingest-Endpoint unauthentifiziert.
|
|
* Wer den Wildcard-Origin einschränken will, muss den Header im Proxy
|
|
überschreiben, nicht nur ergänzen — sonst stehen zwei Werte drin:
|
|
|
|
```nginx
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:2584;
|
|
proxy_hide_header Access-Control-Allow-Origin;
|
|
add_header Access-Control-Allow-Origin "tauri://localhost" always;
|
|
}
|
|
|
|
location /internal/ { deny all; }
|
|
```
|
|
|
|
Eine Konfigurierbarkeit der erlaubten Origins über eine Umgebungsvariable gibt
|
|
es im Code **nicht** — das ist noch offen.
|
|
|
|
### CSP im Client
|
|
|
|
`tauri.conf.json` setzt `connect-src 'self' ipc: http://ipc.localhost`. Die
|
|
HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer
|
|
(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview —
|
|
die CSP muss also für neue Backend-URLs nicht angefasst werden.
|
|
|
|
## 7. Health-Checks und Logs
|
|
|
|
### PDS
|
|
|
|
```
|
|
GET /healthz → {"ok": true}
|
|
GET / → {"name":"maarcadetweet-pds","version":"0.1.0"}
|
|
```
|
|
|
|
`/healthz` ist ein reiner Liveness-Probe ohne DB-Zugriff — ein `200` bedeutet
|
|
nicht, dass Postgres erreichbar ist.
|
|
|
|
Beim Start pingt die PDS zusätzlich MinIO (`S3BlobStore::ping`, ein `HEAD` auf
|
|
`${S3_ENDPOINT}/${S3_BUCKET_PDS}`; `2xx` und `404` gelten als erreichbar). Ein
|
|
Fehlschlag ist **kein** Startabbruch, sondern eine Warnung:
|
|
|
|
```
|
|
s3 ping failed at startup; uploadBlob will serve from local blockstore only
|
|
```
|
|
|
|
### AppView
|
|
|
|
```
|
|
GET /healthz → {"ok":true,"lag_ms":…,"events_processed":…,"jetstream_connected":…}
|
|
GET / → {"name":"maarcadetweet-appview","version":"0.1.0"}
|
|
```
|
|
|
|
Diese drei Felder sind der eigentliche Monitoring-Hook:
|
|
|
|
* `jetstream_connected` — `false` heißt: WebSocket ist weg, der Consumer
|
|
reconnectet mit Backoff (1 s, verdoppelnd bis `max_backoff_secs = 30`).
|
|
* `events_processed` — monoton steigender Zähler. Stagniert er bei
|
|
`jetstream_connected: true`, kommt nichts an.
|
|
* `lag_ms` — Differenz zwischen lokaler Uhr und `time_us` des letzten Events,
|
|
auf `0` geklemmt, wenn negativ. `0` heißt also auch „noch kein Event gesehen".
|
|
|
|
### Logs
|
|
|
|
Beide Binaries loggen über `tracing_subscriber::fmt()` mit
|
|
`EnvFilter::try_from_default_env()` auf stdout/stderr, Fallback-Filter `info`.
|
|
Unter systemd landet alles im Journal:
|
|
|
|
```bash
|
|
journalctl -u maarcadetweet-pds -f
|
|
journalctl -u maarcadetweet-appview -f
|
|
```
|
|
|
|
Empfehlung für den Betrieb (aus `.env.example` abgeleitet, `sqlx` sonst sehr
|
|
gesprächig):
|
|
|
|
```
|
|
RUST_LOG=info,sqlx=warn
|
|
```
|
|
|
|
Log-Zeilen, auf die es sich lohnt zu achten:
|
|
|
|
| Meldung | Bedeutung |
|
|
|---|---|
|
|
| `jetstream error: …` / `reconnecting in Ns` | Firehose-Verbindung verloren |
|
|
| `apply_commit failed; NOT advancing cursor` | Indexer-Fehler, Event wird beim nächsten Replay erneut versucht |
|
|
| `cursor flush failed` | Cursor-Schreibvorgang auf die DB schlug fehl |
|
|
| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar |
|
|
| `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert |
|
|
|
|
## 8. Neustart-Verhalten
|
|
|
|
**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
|
|
(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent
|
|
sind `repo_blocks`, `repos`, `users`, `blobs`, `sessions`, `plc_ops` in der
|
|
PDS-DB. Migrationen laufen bei jedem Start (idempotent über
|
|
`_sqlx_migrations`).
|
|
|
|
**AppView — Jetstream-Cursor.** Der Fortschritt liegt in der Tabelle
|
|
`jetstream_cursor` (genau eine Zeile, `id = 1`, Spalte `cursor` =
|
|
Mikrosekunden seit Epoch). Der Ablauf:
|
|
|
|
1. Beim Start liest `indexer::cursor_get` die Zeile. Ist der Wert `> 0`, wird
|
|
der Consumer mit `with_cursor(...)` gebaut und schickt `cursor` im
|
|
`subscribe`-Options-Frame an Jetstream — die Verbindung setzt dort fort
|
|
statt am Live-Ende. Bei `0` startet er ohne Cursor.
|
|
2. Der Handler zählt jedes Event. Nur jedes 100. Event schickt er einen Tick
|
|
in einen `mpsc`-Kanal (Kapazität 32, `try_send` — bei vollem Kanal wird der
|
|
Tick verworfen).
|
|
3. Ein Hintergrund-Task sammelt die Ticks und schreibt alle 500 ms das Maximum
|
|
per `UPDATE jetstream_cursor SET cursor = GREATEST(cursor, $1)`. Der Cursor
|
|
kann dadurch nie zurückspringen.
|
|
4. Schlägt `apply_commit` fehl, wird der Cursor für dieses Event **nicht**
|
|
vorgerückt — der Reconnect liefert es erneut.
|
|
|
|
Praktische Folge: Nach einem Neustart werden bis zu ~100 Events (plus die
|
|
letzten 500 ms) erneut verarbeitet. Alle Indexer-Schreibpfade sind als Upserts
|
|
gebaut, das Replay ist also unschädlich. Umgekehrt gilt: War die AppView länger
|
|
weg als Jetstreams Backfill-Fenster, sind die Events dazwischen verloren; ein
|
|
Backfill-Werkzeug dafür existiert nicht (noch offen).
|
|
|
|
**Eigene Posts sind vom Cursor unabhängig.** Die PDS pusht jeden lokalen Commit
|
|
direkt an `POST /internal/ingest-commit`. Der Push ist best effort mit 5 s
|
|
Timeout und blockiert den Record-Write nie — fällt er aus, kommt der Record
|
|
später über Jetstream (sofern die Collection in `JETSTREAM_COLLECTIONS` steht,
|
|
siehe Abschnitt 3).
|
|
|
|
**Handle-Sync.** Läuft als eigener Task alle
|
|
`APPVIEW_HANDLE_SYNC_INTERVAL_SECS` Sekunden und füllt leere `posts.handle`
|
|
nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PLC
|
|
bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der
|
|
Zustand ist reine Anzeigekosmetik.
|
|
|
|
## 9. Was noch offen ist
|
|
|
|
* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur
|
|
Postgres und MinIO ab. Es gibt kein Dockerfile im Repo.
|
|
* `at-blob` spricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku in
|
|
`crates/at-blob/src/s3.rs`); echtes AWS S3 funktioniert damit nicht.
|
|
* Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe.
|
|
* Keine konfigurierbare CORS-Allowlist in der AppView.
|
|
* Kein `.well-known`-Handling in der PDS, `describeServer` liefert eine
|
|
hartkodierte DID.
|
|
* Kein Backfill-Werkzeug für Jetstream-Lücken.
|