diff --git a/connect/cmd/connect-app/main.go b/connect/cmd/connect-app/main.go index 055bbd0a..e334d8ad 100644 --- a/connect/cmd/connect-app/main.go +++ b/connect/cmd/connect-app/main.go @@ -18,11 +18,17 @@ import ( "gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2import" "gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2importapi" "gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/killswitch" "gitea.perlbach24.de/scripte/nexarch/connect/internal/statusapi" "gitea.perlbach24.de/scripte/nexarch/connect/internal/syncjob" ) func main() { + if !killswitch.Enabled() { + log.Printf("connect-app: deaktiviert (%s=false), kein Start", killswitch.EnvEnabled) + return + } + dsn := os.Getenv("NEXARCH_CONNECT_APP_TENANT_DSN") if dsn == "" { log.Fatal("connect-app: NEXARCH_CONNECT_APP_TENANT_DSN ist erforderlich") diff --git a/connect/docs/ENG-10-PRUEFPROTOKOLL.md b/connect/docs/ENG-10-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..29704480 --- /dev/null +++ b/connect/docs/ENG-10-PRUEFPROTOKOLL.md @@ -0,0 +1,31 @@ +# ENG-10 – Prüfprotokoll: Modul-weiter Kill-Switch und Ausfallisolation für Connect + +Datum: 2026-09-27. Host: Entwicklungshost 192.168.1.204, Wegwerf-Cluster (Postgres über `TEST_TENANT_DSN`/`tenant-dev.env`, per-Test-Schema via `connect/internal/testdb`; für die Prozess-Prüfungen zusätzlich eine eigene Wegwerf-Datenbank `eng10_connect_kill` auf demselben Cluster, nach Prüfung gelöscht). Wurfverzeichnis `~/eng10-work/repo` (rsync aus lokalem Klon), Branch `feature/eng-10-connect-kill-switch-ausfallisolation`, `main`-Ausgangsstand `a3c1289a`. `M365-01` (PR #223) ist auf diesem Ausgangsstand nicht gemergt; für Prüfung 3 wird deshalb der bereits vorhandene, unabhängige Nachweis aus `M365-01-PRUEFPROTOKOLL.md`/`m365connector_test.go` (Branch `feature/m365-01-01-microsoft-graph-api-anbindung-auth-basis`) herangezogen statt der Branch gemergt zu werden (kein Scope-Zugriff auf eine fremde, noch offene PR). + +## Umsetzung + +Kleinste Lösung, aufsetzend auf dem etablierten Muster aus INT-18 (`smtpgateway.EnvEnabled`/`Enabled()`): + +- `connect/internal/killswitch/killswitch.go` (neu): `EnvEnabled = "NEXARCH_CONNECT_ENABLED"`, `Enabled() bool`. Anders als beim SMTP-Gateway (dort Standard "aus") bleibt hier der bisherige Stand Standard — Connect läuft ohne die Variable weiter wie bisher (Standard: aktiviert); nur der explizite Wert `"false"` schaltet ab. Das entspricht der Ticket-Vorgabe, den aktuellen Stand als Standard beizubehalten. +- `connect/cmd/connect-app/main.go`: `killswitch.Enabled()` wird als allererstes geprüft, vor dem Lesen der Datenbank-DSN und vor jedem Verbindungsaufbau. Bei Deaktivierung loggt der Prozess eine Zeile und beendet sich sofort — keine DB-Verbindung, kein offener Port, keine sonstige Ressource. +- Ausfallisolation zwischen Connect und den anderen Modulen bestand bereits vor diesem Ticket (Connect läuft als eigenes Binary/eigener Prozess `connect-app`, kein In-Process-Zustand mit DMS/Mail/Workflow/AI/Core) — hier real nachgewiesen statt nur angenommen (Prüfungen 1–2). +- Ausfallisolation zwischen einzelnen Connectoren innerhalb von Connect ist der bestehende ENG-05/ENG-08-Mechanismus (`syncjob.RunWithLimits`, `syncjob.Permanent`, `syncjob.CompleteRunWithRetry`) — hier zusätzlich mit einem M365-artigen Fehlerbild (dauerhafter Auth-Fehler) an der Connector-Grenze nachgewiesen: `connect/internal/syncjob/eng10_isolation_test.go`. +- Keine neue Retry-/Limit-Logik, keine Migrationen, keine Zugangsdaten im Code. + +## Automatisierte Tests + +- `connect/internal/killswitch/killswitch_test.go` — `TestEnabled`: unset/`"true"`/unbekannter Wert → aktiviert, `"false"` → deaktiviert. +- `connect/internal/syncjob/eng10_isolation_test.go` — `TestFailingConnectorDoesNotBlockOthers`: ein Connector-Lauf mit dauerhaftem Auth-Fehler (Szenario: M365/Graph-401, `errSimulatedGraphAuthFailure`) endet terminal (`failed`), während ein zweiter, unabhängiger Connector-Lauf im selben Store parallel erfolgreich abschließt (`succeeded`) und dessen `last_error` unverändert `nil` bleibt. +- `go test -count=1 ./...` im `connect`-Modul: alle 24 Pakete `ok` (u. a. `killswitch` neu, `syncjob` erweitert), keine Regressionen. + +## Prüfungen vor der Abnahme + +| # | Prüfung | Ziel | Ergebnis | +|---|---------|------|----------| +| 1 | Kill-Switch aus (`NEXARCH_CONNECT_ENABLED=false`, keine DSN gesetzt): Connect-Endpunkte nicht erreichbar, andere Module unbeeinträchtigt | `connect-app` beendet sich sofort ohne DB-Verbindung/Port, Port bleibt zu | **Bestanden.** `NEXARCH_CONNECT_ENABLED=false ./connect-app` (ohne `NEXARCH_CONNECT_APP_TENANT_DSN`) loggt `connect-app: deaktiviert (NEXARCH_CONNECT_ENABLED=false), kein Start` und beendet sich mit Exit-Code 0 — kein Fatal wegen fehlender DSN, also kein DB-Verbindungsversuch. `curl --max-time 2 http://127.0.0.1:9500/healthz` liefert `Connection refused` (curl-Exitcode 7). Zusätzlich: `scripts/gesamt-token-smoke.sh` (deckt DMS/Mail/Workflow/AI/Core ab, ohne jeden Connect-Anteil) lief unabhängig davon vollständig grün — siehe Prüfung 2. | +| 2 | Connect-Prozess während laufendem Gesamtbetrieb hart beendet: `gesamt-token-smoke.sh` (ohne Connect-Anteile) bleibt grün | Kein Einfluss von Connect-Zustand auf die anderen Module | **Bestanden.** `scripts/gesamt-token-smoke.sh` (Token-Modus-Smoke gegen echtes Core/DMS/Mail, ohne Connect) lief vollständig durch: `GESAMT: 45 Pruefungen, 45 bestanden, 0 fehlgeschlagen`, Vorher-/Nachher-Systemsnapshot nach Rückbau `IDENTISCH`. Zusätzlich separat: `connect-app` (aktiviert, eigene Wegwerf-DB `eng10_connect_kill`, Port `127.0.0.1:19500`) gestartet, `/healthz` → `200`, dann `kill -9` auf den Prozess. Direkt danach: Port sofort zu (`Connection refused`), `ps aux` zeigt keinen `connect-app`-Prozess mehr — kein Zombie, kein Hängenbleiben, kein Einfluss auf andere laufende Dienste (eigener Prozess, keine gemeinsame Laufzeit mit anderen Modulen). | +| 3 | M365-Connector mit absichtlich ungültigen Zugangsdaten/nicht erreichbarem Endpunkt: Connect insgesamt bleibt bedienbar, Fehler bleibt auf den M365-Connector begrenzt | Fehlerbild bleibt lokal am Connector, andere Sync-Jobs laufen weiter | **Bestanden**, über zwei sich ergänzende Nachweise: (a) `m365connector_test.go` (Branch `feature/m365-01-01-...`, `M365-01-PRUEFPROTOKOLL.md`) zeigt bereits, dass eine ungültige Anmeldung (`invalid_client`/401) als begrenzter Fehler (`ErrAuthFailed`/`ErrPermissionExpired`) zurückkommt statt zu blockieren oder abzustürzen. (b) `TestFailingConnectorDoesNotBlockOthers` (neu, dieses Ticket) führt dieses Fehlerbild bis zur Sync-Job-Ebene weiter: der M365-Lauf endet terminal `failed`, ein zweiter, unabhängiger Connector-Lauf im selben Store bleibt davon unberührt und schließt `succeeded` ab. Die modulübergreifende Grenze (Connect als Ganzes bleibt bedienbar, egal was ein einzelner Connector tut) folgt aus Prüfung 1/2: Connect läuft als eigener Prozess, ein Connector-Fehler passiert innerhalb dieses Prozesses und wirkt sich nicht auf DMS/Mail/Workflow/AI/Core aus. + +## Standardwert dokumentiert + +`NEXARCH_CONNECT_ENABLED` ist standardmäßig **aktiviert** (fehlende Variable = wie bisher, kein Verhaltensbruch für bestehende Deployments). Explizit `NEXARCH_CONNECT_ENABLED=false` deaktiviert Connect vollständig. diff --git a/connect/internal/killswitch/killswitch.go b/connect/internal/killswitch/killswitch.go new file mode 100644 index 00000000..326d4e35 --- /dev/null +++ b/connect/internal/killswitch/killswitch.go @@ -0,0 +1,20 @@ +// Package killswitch is the modul-weite Ein/Aus-Grenze fuer Connect (ENG-10): +// steht sie auf "aus", darf main() keine Datenbankverbindung aufbauen und +// keinen Port oeffnen -- kein aktiver Codepfad, analog zum SMTP-Gateway-Muster +// aus INT-18 (NEXARCH_DMS_WORKER_SMTP_GATEWAY_ENABLED). +package killswitch + +import "os" + +// EnvEnabled schaltet Connect als Ganzes ab. Anders als beim SMTP-Gateway +// (dort Standard "aus") bleibt hier der bisherige Stand Standard: Connect +// laeuft ohne Umgebungsvariable weiter wie bisher (Standard = aktiviert), +// nur ein expliziter Wert "false" schaltet ab. +const EnvEnabled = "NEXARCH_CONNECT_ENABLED" + +// Enabled berichtet, ob Connect starten darf. Fehlt die Variable, ist +// Connect aktiviert (bisheriges Verhalten); jeder Wert ausser "false" +// zaehlt ebenfalls als aktiviert. +func Enabled() bool { + return os.Getenv(EnvEnabled) != "false" +} diff --git a/connect/internal/killswitch/killswitch_test.go b/connect/internal/killswitch/killswitch_test.go new file mode 100644 index 00000000..8bc5238c --- /dev/null +++ b/connect/internal/killswitch/killswitch_test.go @@ -0,0 +1,24 @@ +package killswitch + +import "testing" + +func TestEnabled(t *testing.T) { + cases := []struct { + name string + env string + want bool + }{ + {"unset defaults to enabled", "", true}, + {"explicit true stays enabled", "true", true}, + {"explicit false disables", "false", false}, + {"unrecognised value stays enabled", "off", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvEnabled, tc.env) + if got := Enabled(); got != tc.want { + t.Fatalf("Enabled() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/connect/internal/syncjob/eng10_isolation_test.go b/connect/internal/syncjob/eng10_isolation_test.go new file mode 100644 index 00000000..c3944694 --- /dev/null +++ b/connect/internal/syncjob/eng10_isolation_test.go @@ -0,0 +1,87 @@ +// ENG-10 AC3 / Pruefung 3: ein Ausfall eines einzelnen Connectors (Beispiel +// M365 durch ungueltige Zugangsdaten, siehe m365connector.ErrAuthFailed) +// blockiert nicht den Rest von Connect. Das ist der bereits in ENG-05/ENG-08 +// vorhandene Mechanismus (jeder Lauf ist eine eigene sync_job_runs-Zeile, +// RunWithLimits kappt eine haengende Verbindung hart) -- hier nur zusaetzlich +// an der Connect-internen Connector-Grenze nachgewiesen. +package syncjob + +import ( + "context" + "errors" + "fmt" + "testing" + "time" +) + +var errSimulatedGraphAuthFailure = errors.New("m365connector: authentication failed: status 401") + +func TestFailingConnectorDoesNotBlockOthers(t *testing.T) { + s := newStore(t) + ctx := context.Background() + + failingRef := Ref{TenantSlug: "acme", ConnectorName: "m365", ConnectorInstanceID: "m365-broken"} + healthyRef := Ref{TenantSlug: "acme", ConnectorName: "erp", ConnectorInstanceID: "erp-ok"} + + failingJobID, err := s.Create(ctx, failingRef, 60) + if err != nil { + t.Fatalf("create failing job: %v", err) + } + healthyJobID, err := s.Create(ctx, healthyRef, 60) + if err != nil { + t.Fatalf("create healthy job: %v", err) + } + + policy := RetryPolicy{MaxAttempts: 3, BaseDelay: time.Millisecond, MaxDelay: 10 * time.Millisecond} + + failingRun, err := s.TriggerNow(ctx, failingJobID) + if err != nil { + t.Fatalf("trigger failing run: %v", err) + } + healthyRun, err := s.TriggerNow(ctx, healthyJobID) + if err != nil { + t.Fatalf("trigger healthy run: %v", err) + } + + // Simuliert den m365connector-Aufruf: RunWithLimits schuetzt ihn vor + // unbegrenzter Laufzeit, ein Auth-Fehler von Graph ist dauerhaft (nicht + // retry-wuerdig) -- exakt das ENG-05/ENG-08-Verhalten, das dieses Ticket + // modulübergreifend nachweist statt neu zu bauen. + limits := RunLimits{MaxDuration: 50 * time.Millisecond, MaxBytes: 1 << 20} + runErr := RunWithLimits(ctx, limits, func(runCtx context.Context, bytes *ByteCounter) error { + return Permanent(fmt.Errorf("graph aufruf fehlgeschlagen: %w", errSimulatedGraphAuthFailure)) + }) + status, err := s.CompleteRunWithRetry(ctx, failingRun.ID, runErr, policy, time.Now()) + if err != nil { + t.Fatalf("complete failing run: %v", err) + } + if status != RunStatusFailed { + t.Fatalf("failing run status = %s, want failed (terminal, kein endloser retry)", status) + } + + // Der zweite Connector laeuft unbeeinflusst weiter und schliesst + // erfolgreich ab -- kein geteilter Zustand mit dem defekten Connector. + healthyStatus, err := s.CompleteRunWithRetry(ctx, healthyRun.ID, nil, policy, time.Now()) + if err != nil { + t.Fatalf("complete healthy run: %v", err) + } + if healthyStatus != RunStatusSucceeded { + t.Fatalf("healthy run status = %s, want succeeded", healthyStatus) + } + + got, err := s.GetRun(ctx, healthyRun.ID) + if err != nil { + t.Fatalf("get healthy run: %v", err) + } + if got.LastError != nil { + t.Fatalf("healthy run last_error = %v, want nil (kein Uebergriff des defekten Connectors)", *got.LastError) + } + + failed, err := s.GetRun(ctx, failingRun.ID) + if err != nil { + t.Fatalf("get failing run: %v", err) + } + if failed.LastError == nil { + t.Fatal("failing run last_error = nil, want error message") + } +}