diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ce735..f1c1238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Gemeinsames Go-Modul `platform/` mit ENV-Konfiguration und strukturiertem JSON-Logging angelegt. - Forgejo-Actions-CI mit Go/Frontend-Verifikation und Gitleaks-Secret-Scan angelegt. - E0-T01 begonnen: Monorepo-Grundstruktur, Root-Dokumentation, Env-Beispiel und minimale Service-Startpunkte angelegt. - Annahme dokumentiert: vorlaeufiger Modulpfad ist `proxui`. diff --git a/Makefile b/Makefile index 19d9ee2..9a19e89 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,10 @@ migrate: @echo "No migrations configured yet. E2-T01 will wire golang-migrate." test: - env GOCACHE="$(GOCACHE)" go test ./backend/... ./worker/... ./console-proxy/... + env GOCACHE="$(GOCACHE)" go test ./platform/... ./backend/... ./worker/... ./console-proxy/... lint: - env GOCACHE="$(GOCACHE)" go vet ./backend/... ./worker/... ./console-proxy/... + env GOCACHE="$(GOCACHE)" go vet ./platform/... ./backend/... ./worker/... ./console-proxy/... npm run lint --prefix frontend build: diff --git a/README.md b/README.md index e76f98f..04dd535 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Multi-Tenant-Konsole fuer Proxmox auf Basis von Go, Supabase Postgres/Auth, Redi - `backend/`: HTTP-API, Auth/JWT-Validierung, RBAC, DB-Zugriff und Proxmox-Orchestrierung - `worker/`: asynchrone Jobs fuer Proxmox-Tasks, Reconciliation und Cleanup - `console-proxy/`: Websocket-Proxy fuer noVNC/xterm.js ohne direkte Proxmox-Verbindung im Browser +- `platform/`: gemeinsame Go-Bausteine fuer Konfiguration, Logging und spaetere Querschnittsfunktionen - `frontend/`: React/TypeScript/Vite-App fuer Kunden- und Admin-Workflows - `migrations/`: versionierte SQL-Migrationen - `deploy/`: lokale und spaetere Deployment-Artefakte diff --git a/TODO.md b/TODO.md index e55b444..a88364a 100644 --- a/TODO.md +++ b/TODO.md @@ -21,10 +21,14 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] Forgejo-Actions-Workflow fuer Pushes auf `main` und Pull Requests angelegt - [x] Go- und Frontend-Verifikation ueber `make verify` eingebunden - [x] Gitleaks-Secret-Scan mit enger Allowlist fuer offizielle Supabase-Beispieldateien eingebunden +- [x] E0-T04: Konfigurations- und Logging-Layer fuer Go-Dienste + - [x] Gemeinsames Go-Modul `platform/` angelegt + - [x] ENV-basierte Konfiguration mit Defaults und Validierung eingebunden + - [x] Einheitliches strukturiertes JSON-Logging mit Service- und Env-Feldern eingebunden + - [x] Backend, Worker und Console-Proxy auf den gemeinsamen Layer umgestellt ## Naechste Aufgaben -- [ ] E0-T04: Konfigurations- und Logging-Layer fuer Go-Dienste - [ ] E2-T01: Migrations-Setup mit `golang-migrate` ## MVP-Backlog @@ -63,3 +67,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - 2026-06-09: `GET /auth/v1/admin/users` ueber Kong mit Service-Role-Key erfolgreich: leere User-Liste statt Studio-Fehler. - 2026-06-10: CI-Workflow fuer Forgejo Actions angelegt; lokales `make verify` erfolgreich. - 2026-06-10: Gitleaks-Secret-Scan lokal mit `zricethezav/gitleaks:v8.28.0` erfolgreich; keine Leaks gefunden. +- 2026-06-10: Gemeinsamer Go-Config-/Logging-Layer angelegt; Tests fuer Config-Defaults und Validierung ergaenzt. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 87922ac..4f1bae1 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -3,17 +3,25 @@ package main import ( "context" "errors" - "log/slog" "net/http" "os" "os/signal" "syscall" "time" + + "forgejo.digital-droplets.de/philschlo/proxui/platform/config" + "forgejo.digital-droplets.de/philschlo/proxui/platform/logging" ) func main() { - addr := getenv("BACKEND_ADDR", ":8080") - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + cfg, err := config.Load() + if err != nil { + logger := logging.New("backend", "unknown", 0) + logger.Error("failed to load config", "error", err) + os.Exit(1) + } + + logger := logging.New("backend", cfg.AppEnv, cfg.LogLevel) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -22,7 +30,7 @@ func main() { }) server := &http.Server{ - Addr: addr, + Addr: cfg.BackendAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, } @@ -31,7 +39,7 @@ func main() { defer stop() go func() { - logger.Info("backend listening", "addr", addr) + logger.Info("backend listening", "addr", cfg.BackendAddr) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("backend failed", "error", err) os.Exit(1) @@ -47,10 +55,3 @@ func main() { os.Exit(1) } } - -func getenv(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - return fallback -} diff --git a/console-proxy/cmd/console-proxy/main.go b/console-proxy/cmd/console-proxy/main.go index c47a537..3249370 100644 --- a/console-proxy/cmd/console-proxy/main.go +++ b/console-proxy/cmd/console-proxy/main.go @@ -3,17 +3,25 @@ package main import ( "context" "errors" - "log/slog" "net/http" "os" "os/signal" "syscall" "time" + + "forgejo.digital-droplets.de/philschlo/proxui/platform/config" + "forgejo.digital-droplets.de/philschlo/proxui/platform/logging" ) func main() { - addr := getenv("CONSOLE_PROXY_ADDR", ":8081") - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + cfg, err := config.Load() + if err != nil { + logger := logging.New("console-proxy", "unknown", 0) + logger.Error("failed to load config", "error", err) + os.Exit(1) + } + + logger := logging.New("console-proxy", cfg.AppEnv, cfg.LogLevel) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -22,7 +30,7 @@ func main() { }) server := &http.Server{ - Addr: addr, + Addr: cfg.ConsoleProxyAddr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, } @@ -31,7 +39,7 @@ func main() { defer stop() go func() { - logger.Info("console proxy listening", "addr", addr) + logger.Info("console proxy listening", "addr", cfg.ConsoleProxyAddr) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("console proxy failed", "error", err) os.Exit(1) @@ -47,10 +55,3 @@ func main() { os.Exit(1) } } - -func getenv(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - return fallback -} diff --git a/go.work b/go.work index 459fd4f..5bc8f33 100644 --- a/go.work +++ b/go.work @@ -3,5 +3,6 @@ go 1.22 use ( ./backend ./console-proxy + ./platform ./worker ) diff --git a/platform/config/config.go b/platform/config/config.go new file mode 100644 index 0000000..12be3e4 --- /dev/null +++ b/platform/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "log/slog" + "os" + "strconv" + "strings" +) + +type Config struct { + AppEnv string + LogLevel slog.Level + BackendAddr string + ConsoleProxyAddr string + WorkerConcurrency int + DatabaseURL string + DatabaseDirectURL string + SupabaseURL string + SupabaseJWKSURL string + SupabaseIssuer string + RedisAddr string +} + +func Load() (Config, error) { + logLevel, err := parseLogLevel(getenv("LOG_LEVEL", "info")) + if err != nil { + return Config{}, err + } + + workerConcurrency, err := intFromEnv("WORKER_CONCURRENCY", 5) + if err != nil { + return Config{}, err + } + if workerConcurrency < 1 { + return Config{}, fmt.Errorf("WORKER_CONCURRENCY must be greater than 0") + } + + return Config{ + AppEnv: getenv("APP_ENV", "development"), + LogLevel: logLevel, + BackendAddr: getenv("BACKEND_ADDR", ":8080"), + ConsoleProxyAddr: getenv("CONSOLE_PROXY_ADDR", ":8081"), + WorkerConcurrency: workerConcurrency, + DatabaseURL: os.Getenv("DATABASE_URL"), + DatabaseDirectURL: os.Getenv("DATABASE_DIRECT_URL"), + SupabaseURL: os.Getenv("SUPABASE_URL"), + SupabaseJWKSURL: os.Getenv("SUPABASE_JWKS_URL"), + SupabaseIssuer: os.Getenv("SUPABASE_ISSUER"), + RedisAddr: getenv("REDIS_ADDR", "localhost:6379"), + }, nil +} + +func getenv(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func intFromEnv(key string, fallback int) (int, error) { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback, nil + } + + value, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("%s must be an integer: %w", key, err) + } + return value, nil +} + +func parseLogLevel(raw string) (slog.Level, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "debug": + return slog.LevelDebug, nil + case "info": + return slog.LevelInfo, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return slog.LevelInfo, fmt.Errorf("LOG_LEVEL must be one of debug, info, warn, error") + } +} diff --git a/platform/config/config_test.go b/platform/config/config_test.go new file mode 100644 index 0000000..6b06b34 --- /dev/null +++ b/platform/config/config_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "log/slog" + "testing" +) + +func TestLoadDefaults(t *testing.T) { + clearConfigEnv(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.AppEnv != "development" { + t.Fatalf("AppEnv = %q, want development", cfg.AppEnv) + } + if cfg.LogLevel != slog.LevelInfo { + t.Fatalf("LogLevel = %v, want info", cfg.LogLevel) + } + if cfg.BackendAddr != ":8080" { + t.Fatalf("BackendAddr = %q, want :8080", cfg.BackendAddr) + } + if cfg.ConsoleProxyAddr != ":8081" { + t.Fatalf("ConsoleProxyAddr = %q, want :8081", cfg.ConsoleProxyAddr) + } + if cfg.WorkerConcurrency != 5 { + t.Fatalf("WorkerConcurrency = %d, want 5", cfg.WorkerConcurrency) + } + if cfg.RedisAddr != "localhost:6379" { + t.Fatalf("RedisAddr = %q, want localhost:6379", cfg.RedisAddr) + } +} + +func TestLoadOverrides(t *testing.T) { + clearConfigEnv(t) + + t.Setenv("APP_ENV", "test") + t.Setenv("LOG_LEVEL", "debug") + t.Setenv("BACKEND_ADDR", ":9000") + t.Setenv("CONSOLE_PROXY_ADDR", ":9001") + t.Setenv("WORKER_CONCURRENCY", "12") + t.Setenv("REDIS_ADDR", "redis:6379") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.AppEnv != "test" { + t.Fatalf("AppEnv = %q, want test", cfg.AppEnv) + } + if cfg.LogLevel != slog.LevelDebug { + t.Fatalf("LogLevel = %v, want debug", cfg.LogLevel) + } + if cfg.BackendAddr != ":9000" { + t.Fatalf("BackendAddr = %q, want :9000", cfg.BackendAddr) + } + if cfg.ConsoleProxyAddr != ":9001" { + t.Fatalf("ConsoleProxyAddr = %q, want :9001", cfg.ConsoleProxyAddr) + } + if cfg.WorkerConcurrency != 12 { + t.Fatalf("WorkerConcurrency = %d, want 12", cfg.WorkerConcurrency) + } + if cfg.RedisAddr != "redis:6379" { + t.Fatalf("RedisAddr = %q, want redis:6379", cfg.RedisAddr) + } +} + +func TestLoadRejectsInvalidLogLevel(t *testing.T) { + clearConfigEnv(t) + + t.Setenv("LOG_LEVEL", "trace") + + if _, err := Load(); err == nil { + t.Fatal("Load() error = nil, want error") + } +} + +func TestLoadRejectsInvalidWorkerConcurrency(t *testing.T) { + clearConfigEnv(t) + + t.Setenv("WORKER_CONCURRENCY", "0") + + if _, err := Load(); err == nil { + t.Fatal("Load() error = nil, want error") + } +} + +func clearConfigEnv(t *testing.T) { + t.Helper() + + for _, key := range []string{ + "APP_ENV", + "LOG_LEVEL", + "BACKEND_ADDR", + "CONSOLE_PROXY_ADDR", + "WORKER_CONCURRENCY", + "DATABASE_URL", + "DATABASE_DIRECT_URL", + "SUPABASE_URL", + "SUPABASE_JWKS_URL", + "SUPABASE_ISSUER", + "REDIS_ADDR", + } { + t.Setenv(key, "") + } +} diff --git a/platform/go.mod b/platform/go.mod new file mode 100644 index 0000000..cc1f1ec --- /dev/null +++ b/platform/go.mod @@ -0,0 +1,3 @@ +module forgejo.digital-droplets.de/philschlo/proxui/platform + +go 1.22 diff --git a/platform/logging/logging.go b/platform/logging/logging.go new file mode 100644 index 0000000..097dec0 --- /dev/null +++ b/platform/logging/logging.go @@ -0,0 +1,17 @@ +package logging + +import ( + "log/slog" + "os" +) + +func New(service string, appEnv string, level slog.Level) *slog.Logger { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: level, + }) + + return slog.New(handler).With( + "service", service, + "env", appEnv, + ) +} diff --git a/worker/cmd/worker/main.go b/worker/cmd/worker/main.go index 90fc9a4..55f33bd 100644 --- a/worker/cmd/worker/main.go +++ b/worker/cmd/worker/main.go @@ -2,18 +2,27 @@ package main import ( "context" - "log/slog" "os" "os/signal" "syscall" + + "forgejo.digital-droplets.de/philschlo/proxui/platform/config" + "forgejo.digital-droplets.de/philschlo/proxui/platform/logging" ) func main() { - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + cfg, err := config.Load() + if err != nil { + logger := logging.New("worker", "unknown", 0) + logger.Error("failed to load config", "error", err) + os.Exit(1) + } + + logger := logging.New("worker", cfg.AppEnv, cfg.LogLevel) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - logger.Info("worker started") + logger.Info("worker started", "concurrency", cfg.WorkerConcurrency) <-ctx.Done() logger.Info("worker stopped") }