ENG-10: Connect Kill-Switch und Ausfallisolation #225

Merged
patrick merged 1 commits from feature/eng-10-connect-kill-switch-ausfallisolation into main 2026-09-27 16:29:48 +02:00
5 changed files with 168 additions and 0 deletions

No files matched your search

+6
View File
@@ -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")
+31
View File
@@ -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.
+20
View File
@@ -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"
}
@@ -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)
}
})
}
}
@@ -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")
}
}