From 75bad9dc6058e7c325354cad52d9a3cdcd6ed086 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 28 Sep 2026 00:40:58 +0200 Subject: [PATCH] ENG-11: workflow-app gebuendelter diensteinstiegspunkt Co-Authored-By: Claude Sonnet 5 --- workflow/cmd/workflow-app/main.go | 175 +++++++++++++++++++++++++ workflow/cmd/workflow-app/main_test.go | 169 ++++++++++++++++++++++++ workflow/docs/ENG-11-PRUEFPROTOKOLL.md | 87 ++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 workflow/cmd/workflow-app/main.go create mode 100644 workflow/cmd/workflow-app/main_test.go create mode 100644 workflow/docs/ENG-11-PRUEFPROTOKOLL.md diff --git a/workflow/cmd/workflow-app/main.go b/workflow/cmd/workflow-app/main.go new file mode 100644 index 00000000..e4c33d8b --- /dev/null +++ b/workflow/cmd/workflow-app/main.go @@ -0,0 +1,175 @@ +// Command workflow-app ist der gebuendelte Diensteinstiegspunkt fuer das +// NEXARCH-Workflow-Modul (ENG-11): bindet alle bereits fertigen +// HTTP-API-Pakete an einen einzigen net/http.ServeMux, analog +// connect/cmd/connect-app. Reines Wiring, keine neue Fachlogik. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/approval" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/approvalapi" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/backchannel" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/backchannelapi" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/caseapi" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/caseobj" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/caseoverview" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/caseoverviewapi" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/escalation" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/escalationnotify" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/moduleadapter" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/notifyclient" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/reminder" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingrule" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingruleapi" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/task" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/tenantregistryclient" +) + +func requireEnv(name string) string { + v := os.Getenv(name) + if v == "" { + log.Fatalf("workflow-app: %s muss gesetzt sein", name) + } + return v +} + +func main() { + dsn := requireEnv("NEXARCH_WORKFLOW_APP_TENANT_DSN") + serviceToken := requireEnv("NEXARCH_WORKFLOW_APP_SERVICE_TOKEN") + tenantSlug := requireEnv("NEXARCH_WORKFLOW_APP_TENANT_SLUG") + tenantRegistryURL := requireEnv("NEXARCH_TENANT_REGISTRY_URL") + tenantRegistryToken := os.Getenv("NEXARCH_TENANT_REGISTRY_SERVICE_TOKEN") + + addr := os.Getenv("NEXARCH_WORKFLOW_APP_LISTEN_ADDR") + if addr == "" { + addr = "127.0.0.1:9600" + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("workflow-app: datenbankverbindung: %v", err) + } + defer pool.Close() + + taskStore := task.NewStore(pool) + mux, err := buildMux(pool, taskStore, serviceToken, tenantSlug, tenantRegistryURL, tenantRegistryToken) + if err != nil { + log.Fatalf("workflow-app: %v", err) + } + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("workflow-app: listening on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("workflow-app: %v", err) + } + }() + + stopEscalation := startEscalationDispatch(ctx, pool, taskStore, tenantSlug) + defer stopEscalation() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) +} + +// buildMux verdrahtet alle bestehenden Workflow-API-Pakete auf einen +// gemeinsamen ServeMux (Akzeptanzkriterium 1) -- ausgelagert aus main(), +// damit ein Test denselben Aufbau real gegen eine Datenbank pruefen kann, +// ohne main() zu duplizieren. +func buildMux(pool *pgxpool.Pool, taskStore *task.Store, serviceToken, tenantSlug, tenantRegistryURL, tenantRegistryToken string) (*http.ServeMux, error) { + engine, err := caseobj.NewEngineWithCase(pool, approval.ProcessDefinition) + if err != nil { + return nil, fmt.Errorf("prozess-engine aufbauen: %w", err) + } + caseSvc := caseobj.NewService(pool, engine) + approvalSvc := approval.NewService(pool, engine) + reminderStore := reminder.NewStore(pool, taskStore) + caseoverviewSvc := caseoverview.NewService(caseSvc, reminderStore) + tenantVerifier := tenantregistryclient.New(tenantRegistryURL, tenantRegistryToken) + backchannelSvc := backchannel.NewService(caseSvc, reminderStore, tenantSlug, tenantVerifier) + routingruleStore := routingrule.NewStore(pool) + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + caseapi.NewServer(caseSvc).RegisterRoutes(mux) + approvalapi.NewServer(approvalSvc).RegisterRoutes(mux) + caseoverviewapi.NewServer(caseoverviewSvc).RegisterRoutes(mux) + backchannelapi.NewServer(backchannelSvc).RegisterRoutes(mux) + routingruleapi.NewServer(routingruleStore).RegisterRoutes(mux) + moduleadapter.NewServer(pool, serviceToken, tenantSlug).RegisterRoutes(mux) + return mux, nil +} + +// startEscalationDispatch startet den periodischen TSK-05-Versand +// (escalationnotify.RunAt) als Ticker-Goroutine, da escalation.ProcessDue +// sonst nie in Produktion aufgerufen wird -- es gibt hier anders als bei +// RET-07/BAK-08 keinen systemd-Timer als externen Aufrufer. Die Dispatch- +// Konfiguration ist bewusst optional: fehlt sie, startet der Dienst +// trotzdem (Fristen/Eskalationen sind kein Kernbetriebsrisiko wie eine +// fehlende DSN), nur ohne Benachrichtigungsversand. +func startEscalationDispatch(ctx context.Context, pool *pgxpool.Pool, taskStore *task.Store, tenantSlug string) func() { + notifyBaseURL := os.Getenv("NEXARCH_WORKFLOW_APP_NOTIFY_BASE_URL") + if notifyBaseURL == "" { + log.Print("workflow-app: NEXARCH_WORKFLOW_APP_NOTIFY_BASE_URL nicht gesetzt, Eskalations-Benachrichtigung (TSK-05) laeuft nicht") + return func() {} + } + notifyServiceToken := requireEnv("NEXARCH_WORKFLOW_APP_NOTIFY_SERVICE_TOKEN") + interval := 5 * time.Minute + if raw := os.Getenv("NEXARCH_WORKFLOW_APP_ESCALATION_INTERVAL"); raw != "" { + parsed, err := time.ParseDuration(raw) + if err != nil { + log.Fatalf("workflow-app: NEXARCH_WORKFLOW_APP_ESCALATION_INTERVAL: %v", err) + } + interval = parsed + } + + escalationStore := escalation.NewStore(pool) + notifyClient := notifyclient.New(notifyBaseURL, notifyServiceToken) + + loopCtx, cancel := context.WithCancel(ctx) + ticker := time.NewTicker(interval) + go func() { + defer ticker.Stop() + for { + select { + case <-loopCtx.Done(): + return + case now := <-ticker.C: + dispatched, err := escalationnotify.RunAt(loopCtx, escalationStore, taskStore, notifyClient, tenantSlug, nil, now) + if err != nil { + log.Printf("workflow-app: eskalations-durchlauf fehlgeschlagen: %v", err) + continue + } + if dispatched > 0 { + log.Printf("workflow-app: %d eskalation(en) benachrichtigt", dispatched) + } + } + } + }() + log.Printf("workflow-app: eskalations-benachrichtigung aktiv, intervall %s", interval) + return cancel +} diff --git a/workflow/cmd/workflow-app/main_test.go b/workflow/cmd/workflow-app/main_test.go new file mode 100644 index 00000000..59b88484 --- /dev/null +++ b/workflow/cmd/workflow-app/main_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/task" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/testdb" +) + +func findModuleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("go.mod nicht gefunden") + } + dir = parent + } +} + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + + migrations, err := migrate.Load(filepath.Join(findModuleRoot(t), "migrations", "tenant")) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + return pool +} + +// Akzeptanzkriterium 1/4: cmd/workflow-app bindet alle Workflow-API-Pakete +// an einen einzigen ServeMux, jedes Paket beantwortet mindestens einen +// Endpunkt. +func TestBuildMux_AllPackagesAnswer(t *testing.T) { + pool := testPool(t) + taskStore := task.NewStore(pool) + mux, err := buildMux(pool, taskStore, "test-service-token", "mandant-a", "http://127.0.0.1:0", "") + if err != nil { + t.Fatalf("buildMux: %v", err) + } + ts := httptest.NewServer(mux) + defer ts.Close() + + do := func(method, path, userID, serviceToken string, body []byte) *http.Response { + req, err := http.NewRequest(method, ts.URL+path, bytes.NewReader(body)) + if err != nil { + t.Fatalf("request bauen: %v", err) + } + if userID != "" { + req.Header.Set("X-User-Id", userID) + } + if serviceToken != "" { + req.Header.Set("X-Service-Token", serviceToken) + } + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp + } + + // healthz + if resp := do(http.MethodGet, "/healthz", "", "", nil); resp.StatusCode != http.StatusOK { + t.Errorf("/healthz: status = %d, want 200", resp.StatusCode) + } + + // caseapi: POST /cases + caseBody, _ := json.Marshal(map[string]any{"case_number": "A-1", "responsible": "frank"}) + respCase := do(http.MethodPost, "/cases", "frank", "", caseBody) + if respCase.StatusCode != http.StatusCreated { + t.Errorf("caseapi POST /cases: status = %d, want 201", respCase.StatusCode) + } + + // approvalapi: GET /approvals + if resp := do(http.MethodGet, "/approvals", "frank", "", nil); resp.StatusCode != http.StatusOK { + t.Errorf("approvalapi GET /approvals: status = %d, want 200", resp.StatusCode) + } + + // caseoverviewapi: GET /cases + if resp := do(http.MethodGet, "/cases", "frank", "", nil); resp.StatusCode != http.StatusOK { + t.Errorf("caseoverviewapi GET /cases: status = %d, want 200", resp.StatusCode) + } + + // routingruleapi: GET /routing-rules + if resp := do(http.MethodGet, "/routing-rules", "frank", "", nil); resp.StatusCode != http.StatusOK { + t.Errorf("routingruleapi GET /routing-rules: status = %d, want 200", resp.StatusCode) + } + + // moduleadapter: GET /registrations + if resp := do(http.MethodGet, "/registrations", "", "test-service-token", nil); resp.StatusCode != http.StatusOK { + t.Errorf("moduleadapter GET /registrations: status = %d, want 200", resp.StatusCode) + } + + // backchannelapi: die tenant-Registry ist in diesem Test nicht real + // erreichbar -- Nachweis, dass die Route real gebunden ist (eigene + // Fehlermeldung statt generischer 404-Mux-Antwort), reicht fuer AC4. + respBC := do(http.MethodGet, "/objects/dms/document/1/context", "", "", nil) + if respBC.StatusCode != http.StatusUnauthorized { + t.Errorf("backchannelapi GET /objects/...: status = %d, want 401 (eigene Auth-Antwort, kein Mux-404)", respBC.StatusCode) + } +} + +// Akzeptanzkriterium 3 / Pflichtpruefung 2: fehlende Pflicht-Umgebungsvariable +// fuehrt zu einer klaren Fehlermeldung und keinem offenen Port, kein +// stiller Absturz. +func TestMain_MissingRequiredEnv_FailsClosedWithClearMessage(t *testing.T) { + root := findModuleRoot(t) + addr := "127.0.0.1:19601" + + cmd := exec.Command("go", "run", "./cmd/workflow-app") + cmd.Dir = root + cmd.Env = append(os.Environ(), + "NEXARCH_WORKFLOW_APP_LISTEN_ADDR="+addr, + // NEXARCH_WORKFLOW_APP_TENANT_DSN bewusst nicht gesetzt. + "NEXARCH_WORKFLOW_APP_SERVICE_TOKEN=irrelevant", + "NEXARCH_WORKFLOW_APP_TENANT_SLUG=irrelevant", + "NEXARCH_TENANT_REGISTRY_URL=http://127.0.0.1:0", + ) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + err := cmd.Run() + if err == nil { + t.Fatal("workflow-app haette mit fehlendem NEXARCH_WORKFLOW_APP_TENANT_DSN nicht erfolgreich beenden duerfen") + } + if !strings.Contains(out.String(), "NEXARCH_WORKFLOW_APP_TENANT_DSN") { + t.Errorf("erwarte klare Fehlermeldung zur fehlenden Variable, habe: %s", out.String()) + } + + conn, dialErr := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if dialErr == nil { + conn.Close() + t.Fatalf("port %s war trotz fehlender Pflichtvariable offen", addr) + } +} diff --git a/workflow/docs/ENG-11-PRUEFPROTOKOLL.md b/workflow/docs/ENG-11-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..89b124e1 --- /dev/null +++ b/workflow/docs/ENG-11-PRUEFPROTOKOLL.md @@ -0,0 +1,87 @@ +# ENG-11 – Prüfprotokoll: Workflow-App gebündelter Diensteinstiegspunkt + +## Umsetzung + +`workflow/cmd/workflow-app/main.go` bündelt alle bestehenden Workflow-HTTP-API-Pakete +auf einem gemeinsamen `net/http.ServeMux`, analog `connect/cmd/connect-app`: + +- `caseapi` (Akte anlegen/lesen/Zustandsübergang) +- `approvalapi` (Genehmigungsaufgaben) +- `caseoverviewapi` (Akten-/Fristenübersicht) +- `backchannelapi` (Rückkanal für DMS/Mail, ENG-09/VOR-04) +- `routingruleapi` (Routing-Regeln) +- `moduleadapter` (Modul-Registrierung und Ereignisempfang, ENG-09) + +`escalationnotify.RunAt` (TSK-05, Eskalations-Versand über Core CFG-02) läuft +in Produktion sonst nie automatisch — es existiert kein systemd-Timer wie bei +RET-07/BAK-08 dafür. `main()` startet dafür eine Ticker-Goroutine +(`startEscalationDispatch`), sofern die Versand-Konfiguration gesetzt ist. +Fehlt sie, startet der Dienst trotzdem (die Versand-Konfiguration ist nicht +Kernbetriebsrisiko wie eine fehlende DSN), nur ohne Eskalationsversand — mit +klarer Log-Meldung. + +`routingsuggestapi` und `taskapi`/eigenständige `escalationapi` existieren im +aktuellen Codestand nicht als HTTP-Pakete (nur `task`/`escalation` als reine +Bibliotheken ohne `RegisterRoutes`) — nichts zu verdrahten, aus dem Scope +"nur Zusammenführung bestehender Pakete" folgt: keine neuen HTTP-Schichten +für sie angelegt. + +## Umgebungsvariablen + +Pflicht (fehlend → `log.Fatal`, klare deutschsprachige Meldung, kein Port offen): + +- `NEXARCH_WORKFLOW_APP_TENANT_DSN` +- `NEXARCH_WORKFLOW_APP_SERVICE_TOKEN` (moduleadapter, fail-closed per `svcauth.Equal`) +- `NEXARCH_WORKFLOW_APP_TENANT_SLUG` (moduleadapter + backchannel eigener Mandant) +- `NEXARCH_TENANT_REGISTRY_URL` (Core-TEN-01-Bestätigung für backchannel, gleiche + Konvention wie `archive/cmd/moduleadapter-api`) + +Optional: + +- `NEXARCH_WORKFLOW_APP_LISTEN_ADDR` (Default `127.0.0.1:9600`) +- `NEXARCH_TENANT_REGISTRY_SERVICE_TOKEN` +- `NEXARCH_WORKFLOW_APP_NOTIFY_BASE_URL` / `NEXARCH_WORKFLOW_APP_NOTIFY_SERVICE_TOKEN` + (TSK-05-Versand; ohne Base-URL läuft der Dienst ohne Eskalationsversand weiter) +- `NEXARCH_WORKFLOW_APP_ESCALATION_INTERVAL` (Go-Duration, Default `5m`) + +Keine Zugangsdaten im Code — ausschließlich über Umgebungsvariablen (AC2). + +## Automatisierte Tests + +`workflow/cmd/workflow-app/main_test.go`: + +- `TestBuildMux_AllPackagesAnswer` (AC1/AC4): baut denselben Mux wie `main()` + gegen eine echte, frisch migrierte Testdatenbank (`TEST_TENANT_DSN`) und ruft + je einen Endpunkt pro Paket real über HTTP auf. +- `TestMain_MissingRequiredEnv_FailsClosedWithClearMessage` (AC3): startet die + Binary als echten Subprozess ohne `NEXARCH_WORKFLOW_APP_TENANT_DSN`, prüft + nicht-null Exit-Code, die erwartete Fehlermeldung im Log und dass der + konfigurierte Port anschließend nicht erreichbar ist. + +## Prüfungen (Host 192.168.1.204, Wegwerf-DB `eng11_workflow_test`/`eng11_workflow_run`, +Rolle `eng11_workflow`, alles nach Abschluss gelöscht) + +| # | Prüfung | Zielwert | Ergebnis | +|---|---|---|---| +| 1 | Realer Start auf Host 204 mit Wegwerf-DB, ein Aufruf je Paket | jeweils kein 404 vom Mux | `/healthz`=200, `POST /cases`=201 (Akte angelegt), `GET /approvals`=200, `GET /routing-rules`=200, `GET /registrations`=200, `GET /cases`=200, `GET /objects/.../context`=401 (eigene Auth-Antwort, Route real gebunden) | +| 2 | Fehlende Pflicht-Umgebungsvariable | Fehlermeldung, Exit≠0, kein Port offen | `NEXARCH_WORKFLOW_APP_TENANT_DSN muss gesetzt sein`, Exit-Code 1, `curl` auf den konfigurierten Port: Verbindung abgelehnt (Code 000) | +| 3 | Bestehende Modul-Testsuite bleibt grün | `go test ./...` ohne neue Fehlschläge | `go test -p 1 ./...` (workflow-Modul, serialisiert wegen bekannter paralleler Extension-Race in bestehenden Tests, unabhängig von dieser Änderung): alle Pakete `ok` | + +`go vet ./...` im workflow-Modul: keine Befunde. + +## Entscheidungen + +- **Kein neues `cmd/migrate`**: außerhalb des Tickets (nur Zusammenführung + bestehender API-Pakete); Migrationen für die Prüfungen wurden über ein + wegwerf-Hilfsprogramm angewendet, nicht Teil des Commits. +- **Ticker statt systemd-Timer für TSK-05**: Ticket verlangt Einbindung in + `cmd/workflow-app`; ein separates `cmd/`-Binary für den Versand hätte dem + Produkt-DNA-Satz "ein Dienst pro Modul" widersprochen. +- **Escalation-Dispatch optional, übrige Pflichtvariablen hart**: DSN, + Service-Token, Tenant-Slug und Tenant-Registry-URL sind für den + Kernbetrieb des Dienstes unverzichtbar; die Versandkonfiguration für + TSK-05 ist ein optionales Zusatzfeature, dessen Fehlen den Kern-Dienst + nicht verhindern soll. +- **`buildMux`-Extraktion aus `main()`**: minimale Auslagerung, damit der + Aufbau real (mit Datenbank) testbar ist, ohne die Verdrahtung im Test zu + duplizieren; `main()` bleibt sonst strukturell wie `connect-app`. -- 2.47.3