ENG-10: Connect Kill-Switch und Ausfallisolation #225
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user