feat: Add reverse proxy functionality with enhanced routing capabilities

- Introduced IgnoreRequestPath option in proxy configuration to allow exact match routing.
- Implemented proxy_pass directive in routing extension to handle backend requests.
- Enhanced error handling for backend unavailability and timeouts.
- Added integration tests for reverse proxy, including basic requests, exact match routes, regex routes, header forwarding, and query string preservation.
- Created helper functions for setting up test servers and backends, along with assertion utilities for response validation.
- Updated server initialization to support extension management and middleware chaining.
- Improved logging for debugging purposes during request handling.
This commit is contained in:
Илья Глазунов
2025-12-12 00:38:30 +03:00
parent 8f5b9a5cd1
commit 881028c1e6
17 changed files with 3574 additions and 176 deletions
+287 -85
View File
@@ -1,136 +1,338 @@
// Package logging provides structured logging with zap
package logging
import (
"fmt"
"os"
"time"
"path/filepath"
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
"github.com/konduktor/konduktor/internal/config"
)
// Config is a simple configuration for basic logger setup
type Config struct {
Level string
TimestampFormat string
}
// Logger wraps zap.SugaredLogger with additional functionality
type Logger struct {
level string
timestampFormat string
configFull *config.LoggingConfig
*zap.SugaredLogger
zap *zap.Logger
config *config.LoggingConfig
name string
}
// New creates a new Logger with basic configuration
func New(cfg Config) (*Logger, error) {
level := parseLevel(cfg.Level)
timestampFormat := cfg.TimestampFormat
if timestampFormat == "" {
timestampFormat = "2006-01-02 15:04:05"
}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "timestamp"
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(timestampFormat)
encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig),
zapcore.AddSync(os.Stdout),
level,
)
zapLogger := zap.New(core)
return &Logger{
level: cfg.Level,
timestampFormat: timestampFormat,
SugaredLogger: zapLogger.Sugar(),
zap: zapLogger,
name: "konduktor",
}, nil
}
// NewFromConfig creates a Logger from full LoggingConfig
func NewFromConfig(cfg config.LoggingConfig) (*Logger, error) {
timestampFormat := cfg.Format.TimestampFormat
var cores []zapcore.Core
// Parse main level
mainLevel := parseLevel(cfg.Level)
// Add console core if enabled
if cfg.ConsoleOutput {
consoleLevel := mainLevel
if cfg.Console != nil && cfg.Console.Level != "" {
consoleLevel = parseLevel(cfg.Console.Level)
}
var consoleEncoder zapcore.Encoder
formatConfig := cfg.Format
if cfg.Console != nil {
formatConfig = mergeFormatConfig(cfg.Format, cfg.Console.Format)
}
encoderCfg := createEncoderConfig(formatConfig)
if formatConfig.Type == "json" {
consoleEncoder = zapcore.NewJSONEncoder(encoderCfg)
} else {
if formatConfig.UseColors {
encoderCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
consoleEncoder = zapcore.NewConsoleEncoder(encoderCfg)
}
consoleSyncer := zapcore.AddSync(os.Stdout)
cores = append(cores, zapcore.NewCore(consoleEncoder, consoleSyncer, consoleLevel))
}
// Add file cores
for _, fileConfig := range cfg.Files {
fileCore, err := createFileCore(fileConfig, cfg.Format, mainLevel)
if err != nil {
return nil, fmt.Errorf("failed to create file logger for %s: %w", fileConfig.Path, err)
}
// If specific loggers are configured, wrap with filter
if len(fileConfig.Loggers) > 0 {
fileCore = &filteredCore{
Core: fileCore,
loggers: fileConfig.Loggers,
}
}
cores = append(cores, fileCore)
}
// If no cores configured, add default console
if len(cores) == 0 {
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.EncodeTime = zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05")
encoderCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
cores = append(cores, zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderCfg),
zapcore.AddSync(os.Stdout),
mainLevel,
))
}
// Combine all cores
core := zapcore.NewTee(cores...)
zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
return &Logger{
SugaredLogger: zapLogger.Sugar(),
zap: zapLogger,
config: &cfg,
name: "konduktor",
}, nil
}
// Named returns a logger with a specific name (for filtering)
func (l *Logger) Named(name string) *Logger {
return &Logger{
SugaredLogger: l.SugaredLogger.Named(name),
zap: l.zap.Named(name),
config: l.config,
name: name,
}
}
// With returns a logger with additional fields
func (l *Logger) With(args ...interface{}) *Logger {
return &Logger{
SugaredLogger: l.SugaredLogger.With(args...),
zap: l.zap.Sugar().With(args...).Desugar(),
config: l.config,
name: l.name,
}
}
// Sync flushes any buffered log entries
func (l *Logger) Sync() error {
return l.zap.Sync()
}
// GetZap returns the underlying zap.Logger
func (l *Logger) GetZap() *zap.Logger {
return l.zap
}
// Debug logs a debug message
func (l *Logger) Debug(msg string, keysAndValues ...interface{}) {
l.SugaredLogger.Debugw(msg, keysAndValues...)
}
// Info logs an info message
func (l *Logger) Info(msg string, keysAndValues ...interface{}) {
l.SugaredLogger.Infow(msg, keysAndValues...)
}
// Warn logs a warning message
func (l *Logger) Warn(msg string, keysAndValues ...interface{}) {
l.SugaredLogger.Warnw(msg, keysAndValues...)
}
// Error logs an error message
func (l *Logger) Error(msg string, keysAndValues ...interface{}) {
l.SugaredLogger.Errorw(msg, keysAndValues...)
}
// Fatal logs a fatal message and exits
func (l *Logger) Fatal(msg string, keysAndValues ...interface{}) {
l.SugaredLogger.Fatalw(msg, keysAndValues...)
}
// --- Helper functions ---
func parseLevel(level string) zapcore.Level {
switch strings.ToUpper(level) {
case "DEBUG":
return zapcore.DebugLevel
case "INFO":
return zapcore.InfoLevel
case "WARN", "WARNING":
return zapcore.WarnLevel
case "ERROR":
return zapcore.ErrorLevel
case "CRITICAL", "FATAL":
return zapcore.FatalLevel
default:
return zapcore.InfoLevel
}
}
func createEncoderConfig(format config.LogFormatConfig) zapcore.EncoderConfig {
timestampFormat := format.TimestampFormat
if timestampFormat == "" {
timestampFormat = "2006-01-02 15:04:05"
}
return &Logger{
level: cfg.Level,
timestampFormat: timestampFormat,
configFull: &cfg,
}, nil
cfg := zapcore.EncoderConfig{
TimeKey: "timestamp",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
FunctionKey: zapcore.OmitKey,
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalLevelEncoder,
EncodeTime: zapcore.TimeEncoderOfLayout(timestampFormat),
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
if !format.ShowModule {
cfg.NameKey = zapcore.OmitKey
}
return cfg
}
func (l *Logger) formatTime() string {
return time.Now().Format(l.timestampFormat)
func mergeFormatConfig(base, override config.LogFormatConfig) config.LogFormatConfig {
result := base
if override.Type != "" {
result.Type = override.Type
}
if override.TimestampFormat != "" {
result.TimestampFormat = override.TimestampFormat
}
// UseColors and ShowModule are bool - check if override has non-default
result.UseColors = override.UseColors
result.ShowModule = override.ShowModule
return result
}
func (l *Logger) log(level string, msg string, fields ...interface{}) {
timestamp := l.formatTime()
// Simple console output for now
// TODO: Implement proper structured logging with zap
output := timestamp + " [" + level + "] " + msg
if len(fields) > 0 {
output += " {"
for i := 0; i < len(fields); i += 2 {
if i > 0 {
output += ", "
}
if i+1 < len(fields) {
output += fields[i].(string) + "=" + formatValue(fields[i+1])
}
func createFileCore(fileConfig config.FileLogConfig, defaultFormat config.LogFormatConfig, defaultLevel zapcore.Level) (zapcore.Core, error) {
// Ensure directory exists
dir := filepath.Dir(fileConfig.Path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create log directory %s: %w", dir, err)
}
output += "}"
}
os.Stdout.WriteString(output + "\n")
// Configure log rotation with lumberjack
maxSize := 10 // MB
if fileConfig.MaxBytes > 0 {
maxSize = int(fileConfig.MaxBytes / (1024 * 1024))
if maxSize < 1 {
maxSize = 1
}
}
backupCount := 5
if fileConfig.BackupCount > 0 {
backupCount = fileConfig.BackupCount
}
rotator := &lumberjack.Logger{
Filename: fileConfig.Path,
MaxSize: maxSize,
MaxBackups: backupCount,
MaxAge: 30, // days
Compress: true,
}
// Determine level
level := defaultLevel
if fileConfig.Level != "" {
level = parseLevel(fileConfig.Level)
}
// Create encoder
format := defaultFormat
if fileConfig.Format.Type != "" {
format = mergeFormatConfig(defaultFormat, fileConfig.Format)
}
// Files should not use colors
format.UseColors = false
encoderConfig := createEncoderConfig(format)
var encoder zapcore.Encoder
if format.Type == "json" {
encoder = zapcore.NewJSONEncoder(encoderConfig)
} else {
encoder = zapcore.NewConsoleEncoder(encoderConfig)
}
return zapcore.NewCore(encoder, zapcore.AddSync(rotator), level), nil
}
func formatValue(v interface{}) string {
switch val := v.(type) {
case string:
return val
case int:
return fmt.Sprintf("%d", val)
case int64:
return fmt.Sprintf("%d", val)
case float64:
return fmt.Sprintf("%.2f", val)
case bool:
return fmt.Sprintf("%t", val)
case error:
return val.Error()
default:
return fmt.Sprintf("%v", val)
}
// filteredCore wraps a Core to filter by logger name
type filteredCore struct {
zapcore.Core
loggers []string
}
func (l *Logger) Debug(msg string, fields ...interface{}) {
if l.shouldLog("DEBUG") {
l.log("DEBUG", msg, fields...)
func (c *filteredCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if !c.shouldLog(entry.LoggerName) {
return ce
}
return c.Core.Check(entry, ce)
}
func (l *Logger) Info(msg string, fields ...interface{}) {
if l.shouldLog("INFO") {
l.log("INFO", msg, fields...)
func (c *filteredCore) shouldLog(loggerName string) bool {
if len(c.loggers) == 0 {
return true
}
for _, allowed := range c.loggers {
if loggerName == allowed || strings.HasPrefix(loggerName, allowed+".") {
return true
}
}
return false
}
func (l *Logger) Warn(msg string, fields ...interface{}) {
if l.shouldLog("WARN") {
l.log("WARN", msg, fields...)
func (c *filteredCore) With(fields []zapcore.Field) zapcore.Core {
return &filteredCore{
Core: c.Core.With(fields),
loggers: c.loggers,
}
}
func (l *Logger) Error(msg string, fields ...interface{}) {
if l.shouldLog("ERROR") {
l.log("ERROR", msg, fields...)
}
}
func (l *Logger) shouldLog(level string) bool {
levels := map[string]int{
"DEBUG": 0,
"INFO": 1,
"WARN": 2,
"ERROR": 3,
}
currentLevel, ok := levels[l.level]
if !ok {
currentLevel = 1 // Default to INFO
}
msgLevel, ok := levels[level]
if !ok {
msgLevel = 1
}
return msgLevel >= currentLevel
}
+110 -73
View File
@@ -2,6 +2,8 @@ package logging
import (
"testing"
"github.com/konduktor/konduktor/internal/config"
)
func TestNew(t *testing.T) {
@@ -15,71 +17,84 @@ func TestNew(t *testing.T) {
t.Fatal("Expected logger, got nil")
}
if logger.level != "INFO" {
t.Errorf("Expected level INFO, got %s", logger.level)
if logger.name != "konduktor" {
t.Errorf("Expected name konduktor, got %s", logger.name)
}
}
func TestNew_DefaultTimestampFormat(t *testing.T) {
logger, _ := New(Config{Level: "DEBUG"})
logger, err := New(Config{Level: "DEBUG"})
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if logger.timestampFormat != "2006-01-02 15:04:05" {
t.Errorf("Expected default timestamp format, got %s", logger.timestampFormat)
// Logger should be created successfully
if logger == nil {
t.Fatal("Expected logger, got nil")
}
}
func TestNew_CustomTimestampFormat(t *testing.T) {
logger, _ := New(Config{
logger, err := New(Config{
Level: "DEBUG",
TimestampFormat: "15:04:05",
})
if logger.timestampFormat != "15:04:05" {
t.Errorf("Expected custom timestamp format, got %s", logger.timestampFormat)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if logger == nil {
t.Fatal("Expected logger, got nil")
}
}
func TestLogger_ShouldLog(t *testing.T) {
tests := []struct {
loggerLevel string
msgLevel string
shouldLog bool
}{
{"DEBUG", "DEBUG", true},
{"DEBUG", "INFO", true},
{"DEBUG", "WARN", true},
{"DEBUG", "ERROR", true},
{"INFO", "DEBUG", false},
{"INFO", "INFO", true},
{"INFO", "WARN", true},
{"INFO", "ERROR", true},
{"WARN", "DEBUG", false},
{"WARN", "INFO", false},
{"WARN", "WARN", true},
{"WARN", "ERROR", true},
{"ERROR", "DEBUG", false},
{"ERROR", "INFO", false},
{"ERROR", "WARN", false},
{"ERROR", "ERROR", true},
func TestNewFromConfig(t *testing.T) {
cfg := config.LoggingConfig{
Level: "DEBUG",
ConsoleOutput: true,
Format: config.LogFormatConfig{
Type: "standard",
UseColors: true,
ShowModule: true,
TimestampFormat: "2006-01-02 15:04:05",
},
}
for _, tt := range tests {
t.Run(tt.loggerLevel+"_"+tt.msgLevel, func(t *testing.T) {
logger, _ := New(Config{Level: tt.loggerLevel})
logger, err := NewFromConfig(cfg)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if got := logger.shouldLog(tt.msgLevel); got != tt.shouldLog {
t.Errorf("shouldLog(%s) = %v, want %v", tt.msgLevel, got, tt.shouldLog)
}
})
if logger == nil {
t.Fatal("Expected logger, got nil")
}
}
func TestLogger_ShouldLog_InvalidLevel(t *testing.T) {
logger, _ := New(Config{Level: "INVALID"})
func TestNewFromConfig_WithConsole(t *testing.T) {
cfg := config.LoggingConfig{
Level: "INFO",
ConsoleOutput: true,
Format: config.LogFormatConfig{
Type: "standard",
UseColors: true,
},
Console: &config.ConsoleLogConfig{
Level: "DEBUG",
Format: config.LogFormatConfig{
Type: "standard",
UseColors: false,
},
},
}
// Should default to INFO level
if !logger.shouldLog("INFO") {
t.Error("Invalid level should default to INFO")
logger, err := NewFromConfig(cfg)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if logger == nil {
t.Fatal("Expected logger, got nil")
}
}
@@ -111,34 +126,65 @@ func TestLogger_Error(t *testing.T) {
logger.Error("test message", "key", "value")
}
func TestFormatValue(t *testing.T) {
func TestLogger_Named(t *testing.T) {
logger, _ := New(Config{Level: "INFO"})
named := logger.Named("test.module")
if named == nil {
t.Fatal("Expected named logger, got nil")
}
if named.name != "test.module" {
t.Errorf("Expected name 'test.module', got %s", named.name)
}
// Should not panic
named.Info("test from named logger")
}
func TestLogger_With(t *testing.T) {
logger, _ := New(Config{Level: "INFO"})
withFields := logger.With("service", "test")
if withFields == nil {
t.Fatal("Expected logger with fields, got nil")
}
// Should not panic
withFields.Info("test with fields")
}
func TestLogger_Sync(t *testing.T) {
logger, _ := New(Config{Level: "INFO"})
// Should not panic
err := logger.Sync()
// Sync may return an error for stdout on some systems, ignore it
_ = err
}
func TestParseLevel(t *testing.T) {
tests := []struct {
input interface{}
input string
expected string
}{
{"test", "test"},
{42, "*"}, // int converts to rune
{nil, ""},
{"DEBUG", "debug"},
{"INFO", "info"},
{"WARN", "warn"},
{"WARNING", "warn"},
{"ERROR", "error"},
{"CRITICAL", "fatal"},
{"FATAL", "fatal"},
{"invalid", "info"}, // defaults to INFO
}
for _, tt := range tests {
got := formatValue(tt.input)
// Just check it doesn't panic
_ = got
}
}
func TestLogger_FormatTime(t *testing.T) {
logger, _ := New(Config{
Level: "INFO",
TimestampFormat: "2006-01-02",
})
result := logger.formatTime()
// Should be in expected format (YYYY-MM-DD)
if len(result) != 10 {
t.Errorf("Expected date format YYYY-MM-DD, got %s", result)
t.Run(tt.input, func(t *testing.T) {
level := parseLevel(tt.input)
if level.String() != tt.expected {
t.Errorf("parseLevel(%s) = %s, want %s", tt.input, level.String(), tt.expected)
}
})
}
}
@@ -161,12 +207,3 @@ func BenchmarkLogger_Debug_Filtered(b *testing.B) {
logger.Debug("test message", "key", "value")
}
}
func BenchmarkLogger_ShouldLog(b *testing.B) {
logger, _ := New(Config{Level: "INFO"})
b.ResetTimer()
for i := 0; i < b.N; i++ {
logger.shouldLog("DEBUG")
}
}