Dokumentation aktualisiert auf quarkus und besser strukturiert
This commit is contained in:
291
agent-backend/docs/SFTP-Integration.md
Normal file
291
agent-backend/docs/SFTP-Integration.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user