Files
gala-ki-spielwiese/quarkus-automaton/docs/SFTP-Integration.md

292 lines
9.2 KiB
Markdown
Raw Normal View History

# SSHJ SFTP — Architektur-Übersicht
**Projekt:** n8n Replacement für Dateieingang-Verarbeitung
**Stand:** 2026-04-08
---
## Warum SSHJ statt Camel Quarkus FTP?
Camel Quarkus FTP ist ein EIP-/Routing-Framework, das intern selbst eine SFTP-Bibliothek
wrapped. Da der Trigger ein REST-Endpoint ist (kein eigenes Scheduling, kein Message Routing),
bringt Camel nur Overhead:
| | Camel Quarkus FTP | SSHJ |
|---|---|---|
| **Zweck** | EIP-Framework (Routing, EIP-Patterns, Scheduling) | SFTP/SSH Client |
| **Größe** | Camel Core + FTP-Komponente (~10+ MByte Abhängigkeiten) | Leichtgewichtig |
| **API** | Camel Exchange, RouteBuilder, Endpoints | Einfache Java API |
| **Wartbarkeit** | Camel-Kenntnisse nötig | Standard Java I/O-Konzepte |
| **Quarkus-Integration** | Native Extension vorhanden | Plain CDI-Bean |
| **Eignet sich für** | Komplexe Routing-Szenarien | SFTP-Operationen (list, get, rename) |
Für diesen Use-Case (list ZIPs, download, rename nach Verarbeitung) ist SSHJ die richtige Wahl.
---
## Abhängigkeit
```xml
<!-- Aktuelle Version prüfen: https://mvnrepository.com/artifact/com.hierynomus/sshj -->
<dependency>
<groupId>com.hierynomus</groupId>
<artifactId>sshj</artifactId>
<version>0.38.0</version>
</dependency>
```
SSHJ bringt Bouncy Castle als transitive Abhängigkeit mit (für Kryptografie).
Das ist kein Problem — Bouncy Castle ist weit verbreitet und gut gepflegt.
---
## Pipeline-Architektur
```
REST POST /api/process-incoming (X-Api-Key Header)
▼ 202 Accepted (sofort)
FileProcessingPipeline.processAllAsync()
│ Hintergrund-Thread (ManagedExecutor)
├─ SftpService.listZipFiles()
│ └─ sftp.ls(remotePath) → ["export_2026-04-08.zip", ...]
└─ für jede ZIP:
├─ SftpService.download(filename)
│ └─ sftp.get(remotePath/file, localPath)
├─ ZipExtractionService.extract(localFile)
│ └─ ProcessingContext mit List<FileEntry>
├─ OciUploadService.upload(context)
│ └─ Dateien + Marker in OCI (SimpleAuthenticationDetailsProvider)
├─ SftpService.renameRemote(file, file + ".processed") ← bei Erfolg
│ SftpService.renameRemote(file, file + ".error") ← bei Fehler
├─ OrdsNotificationService.notify(context)
└─ cleanup: lokale ZIP + Entpack-Verzeichnis löschen ← immer (finally)
```
---
## SftpConfig (@ConfigMapping)
```java
@ConfigMapping(prefix = "galabau.sftp")
public interface SftpConfig {
String host();
int port();
String username();
String password();
String hostKeyFingerprint(); // z.B. "SHA256:AbCdEfGh..."
String remotePath();
String localWorkDir();
Optional<String> privateKeyPath();
Optional<String> privateKeyPassphrase();
}
```
---
## SftpService — Implementierungsstruktur
```java
@ApplicationScoped
public class SftpService {
@Inject
SftpConfig config;
/**
* Stellt eine SFTP-Verbindung her, führt die Operation aus und trennt danach sauber.
* Host-Key wird gegen konfigurierten Fingerprint geprüft — kein PromiscuousVerifier.
*/
private <T> T withSftp(SftpOperation<T> operation) throws SftpException {
try (SSHClient ssh = new SSHClient()) {
// Fingerprint-basierte Host-Key-Verifikation (konfigurierbar, sicher)
ssh.addHostKeyVerifier(new FingerprintVerifier(config.hostKeyFingerprint()));
ssh.connect(config.host(), config.port());
if (config.privateKeyPath().isPresent()) {
// Public-Key-Auth (bevorzugt für Produktion)
ssh.authPublickey(config.username(), config.privateKeyPath().get());
} else {
// Password-Auth (Fallback / Entwicklung)
ssh.authPassword(config.username(), config.password());
}
try (SFTPClient sftp = ssh.newSFTPClient()) {
return operation.execute(sftp);
}
} catch (IOException e) {
throw new SftpException("SFTP operation failed: " + e.getMessage(), e);
}
}
public List<String> listZipFiles() throws SftpException {
return withSftp(sftp ->
sftp.ls(config.remotePath()).stream()
.filter(entry -> entry.getName().endsWith(".zip"))
.map(RemoteResourceInfo::getName)
.toList()
);
}
public Path download(String filename) throws SftpException {
Path localFile = Path.of(config.localWorkDir(), filename);
withSftp(sftp -> {
sftp.get(config.remotePath() + "/" + filename, localFile.toString());
return null;
});
return localFile;
}
public void renameRemote(String filename, String newFilename) throws SftpException {
withSftp(sftp -> {
sftp.rename(
config.remotePath() + "/" + filename,
config.remotePath() + "/" + newFilename
);
return null;
});
}
}
@FunctionalInterface
interface SftpOperation<T> {
T execute(SFTPClient sftp) throws IOException;
}
```
---
## Host-Key-Fingerprint ermitteln
Einmalig ausführen (z.B. aus WSL oder Git Bash), bevor der Service deployed wird:
```bash
# Alle Host Keys des Servers anzeigen (SHA256 Fingerprints):
ssh-keyscan sftp.lieferant.de 2>/dev/null | ssh-keygen -lf -
# Ausgabe z.B.:
# 256 SHA256:AbCdEfGhIjKlMnOpQrStUv... sftp.lieferant.de (ED25519)
# 2048 SHA256:XyZaBcDeFgHiJk... sftp.lieferant.de (RSA)
```
Den `SHA256:...`-Teil in `galabau.sftp.host-key-fingerprint` eintragen.
**ED25519 bevorzugen** (stärker und kürzer als RSA).
---
## Fehlerbehandlung
### SFTP-Verbindungsfehler (transient)
```
IOException beim connect/auth/ls
→ SftpException (transient)
→ Pipeline bricht ab
→ Nächster APEX-Lauf (1h) versucht es erneut
→ Alle ZIPs bleiben auf SFTP unverändert
```
### Fehler während ZIP-Verarbeitung
```
ZIP entpackbar?
Nein → ZIP umbenennen zu .error, weiter mit nächster ZIP
OCI Upload erfolgreich?
Nein (transient 503) → @Retry (3x), danach ZIP zu .error
Nein (persistent 403) → ZIP zu .error, Log — Credentials prüfen!
ORDS-Aufruf fehlgeschlagen?
Marker liegt vor → kein Problem, APEX Automation findet ihn nächsten Lauf
```
### Cleanup (immer)
```java
try {
// ... Verarbeitung ...
} finally {
// Lokale Dateien immer löschen — egal ob Erfolg oder Fehler
Files.deleteIfExists(context.localZipPath);
deleteDirectory(context.localExtractDir);
MDC.clear();
}
```
---
## Transient vs. Persistent Fehler
| Fehler | Typ | Handling |
|---|---|---|
| SFTP-Verbindung timeout/refused | transient | Nächster APEX-Lauf |
| OCI 503 Service Unavailable | transient | @Retry (3x mit Backoff) |
| ZIP beschädigt | persistent | `.error`, Log |
| OCI 403 Auth Failed | persistent | `.error`, Log — Config prüfen! |
| SFTP-Rename fehlgeschlagen | transient | Log, ZIP bleibt — nächster Lauf |
---
## n8n → Quarkus Service: Feature-Mapping
| n8n | Quarkus/SSHJ | Anmerkung |
|---|---|---|
| SFTP Trigger (Polling) | entfällt — REST Endpoint | Scheduling bleibt bei APEX Automation |
| SFTP Download | `SftpService.download()` | SSHJ `sftp.get()` |
| Unzip | `ZipExtractionService` | Apache Commons Compress |
| HTTP PUT (OCI) | `OciUploadService` | OCI SDK, Instance Principal |
| HTTP POST (ORDS) | `OrdsNotificationService` | MicroProfile REST Client |
| Try/Catch | Try/Catch + @Retry | Kein Framework-Overhead |
| Rename | `SftpService.renameRemote()` | SSHJ `sftp.rename()` |
| Error Notification | Log + OTLP | Loki/Grafana Alert |
---
## Wartungs-Punkte
### Code-Organisation
- **`SftpService`:** Nur SFTP-Operationen (list, download, rename) — keine Business-Logik
- **`FileProcessingPipeline`:** Einziger Orchestrierer — kennt alle Services, steuert den Ablauf
- **Services:** Keine Abhängigkeiten untereinander — jeder isoliert testbar
- **Config:** `@ConfigMapping` — alle Werte aus `application.properties` / Umgebungsvariablen
### Testing-Strategie
- `SftpService`: Testcontainers + echtem SFTP-Container (`atmoz/sftp` auf Docker Hub)
- `ZipExtractionService`, `OciUploadService`: Unit-Tests mit Testdaten / Mocks
- `FileProcessingPipeline`: Integration-Test mit Mock-Services
- E2E: gegen Staging-SFTP und Staging-OCI
### Monitoring
- OTLP → Loki (via Quarkus OpenTelemetry)
- Dashboards: ZIP-Verarbeitungsrate, Fehler-Rate, Latenz pro Step
- Alerts: `.error`-Dateien > Schwellwert, ORDS-Ausfälle, Pipeline-Laufzeit > Schwellwert
---
## Konfiguration (Zusammenfassung)
```properties
# Host-Key Fingerprint (ssh-keyscan host | ssh-keygen -lf -)
galabau.sftp.host-key-fingerprint=SHA256:AbCdEfGh...
# Password aus Env-Var (Quarkus löst ${...} nativ auf)
galabau.sftp.password=${GALABAU_SFTP_PASSWORD}
# OCI: Credentials aus Env-Vars, Private Key als gemountetes Kubernetes Secret
# galabau.oci.tenancy-id, user-id, fingerprint aus ${ENV_VAR}
# galabau.oci.private-key-path zeigt auf gemountete PEM-Datei
# API-Absicherung des Endpoints
galabau.api.key=${GALABAU_API_KEY}
```
Siehe `plan_n8n_replacement_quarkus.md` für vollständige Konfigurationsübersicht.