ENG-11: workflow-app gebuendelter diensteinstiegspunkt #231

Merged
patrick merged 1 commits from feature/eng-11-workflow-app-diensteinstiegspunkt into main 2026-09-28 01:09:12 +02:00
3 changed files with 431 additions and 0 deletions

No files matched your search

+175
View File
@@ -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
}
+169
View File
@@ -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)
}
}
+87
View File
@@ -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`.