diff --git a/internal/adapter/discovery/service.go b/internal/adapter/discovery/service.go index ac62bea6..136554bd 100644 --- a/internal/adapter/discovery/service.go +++ b/internal/adapter/discovery/service.go @@ -231,6 +231,13 @@ func (s *ModelDiscoveryService) discoverConcurrently(ctx context.Context, endpoi // handleDiscoveryError processes discovery errors and manages endpoint disabling func (s *ModelDiscoveryService) handleDiscoveryError(endpoint *domain.Endpoint, err error) { + // Discovery requests are canceled during coordinated shutdown/restart. + // Treat these as expected lifecycle events, not endpoint failures. + if errors.Is(err, context.Canceled) { + s.logger.InfoWithEndpoint("Model discovery canceled during shutdown", endpoint.Name) + return + } + // Create user-friendly message for console output and detailed error for logs userMsg := GetUserFriendlyMessage(err) diff --git a/internal/adapter/discovery/service_test.go b/internal/adapter/discovery/service_test.go index e495ed73..c86f3839 100644 --- a/internal/adapter/discovery/service_test.go +++ b/internal/adapter/discovery/service_test.go @@ -341,6 +341,40 @@ func TestEndpointDisabledAfterMaxFailures(t *testing.T) { } } +func TestDiscoverEndpointContextCanceledDoesNotCountAsFailure(t *testing.T) { + endpoint := createMockEndpoint("http://localhost:11434", "test-endpoint") + + client := &mockDiscoveryClient{ + discoveryErrors: map[string]error{ + endpoint.URLString: &DiscoveryError{ + EndpointURL: endpoint.URLString, + ProfileType: domain.ProfileOMLX, + Operation: "http_request", + Err: &NetworkError{ + URL: endpoint.URLString + "/v1/models", + Err: context.Canceled, + }, + }, + }, + } + endpointRepo := &mockEndpointRepository{} + modelRegistry := &mockModelRegistry{registeredModels: make([]*domain.ModelInfo, 0)} + service := NewModelDiscoveryService(client, endpointRepo, modelRegistry, DiscoveryConfig{Timeout: 5 * time.Second}, createTestLogger()) + + err := service.DiscoverEndpoint(context.Background(), endpoint) + if err == nil { + t.Fatalf("expected context cancellation error, got nil") + } + + if got := service.getFailureCount(endpoint.URLString); got != 0 { + t.Fatalf("expected no failure count increment for context cancellation, got %d", got) + } + + if service.isEndpointDisabled(endpoint.URLString) { + t.Fatalf("endpoint should not be disabled for context cancellation") + } +} + func TestFilterActiveEndpoints(t *testing.T) { endpoints := []*domain.Endpoint{ createMockEndpoint("http://localhost:11434", "enabled-1"), diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 03e5a32d..35fa653c 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -18,6 +18,7 @@ import ( type Config struct { Level string LogDir string + OutputPath string Theme string MaxSize int // megabytes MaxBackups int @@ -129,12 +130,18 @@ func createJSONHandler(level slog.Level, writer *os.File) slog.Handler { // createFileHandler creates a file-based JSON handler with rotation // wraps with ansiStripHandler to ensure clean JSON output without ANSI codes func createFileHandler(cfg *Config, level slog.Level) (slog.Handler, func(), error) { - if err := os.MkdirAll(cfg.LogDir, 0755); err != nil { + logFilePath := filepath.Join(cfg.LogDir, DefaultLogOutputName) + if cfg.OutputPath != "" { + logFilePath = cfg.OutputPath + } + + logDir := filepath.Dir(logFilePath) + if err := os.MkdirAll(logDir, 0755); err != nil { return nil, nil, err } rotator := &lumberjack.Logger{ - Filename: filepath.Join(cfg.LogDir, DefaultLogOutputName), + Filename: logFilePath, MaxSize: cfg.MaxSize, MaxBackups: cfg.MaxBackups, MaxAge: cfg.MaxAge, diff --git a/main.go b/main.go index 69bc1d7b..937ea7f3 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,9 @@ import ( "log/slog" "os" "os/signal" + "path/filepath" "runtime" + "strings" "syscall" "time" @@ -77,8 +79,15 @@ func main() { vlog.Printf(theme.ColourProfiler("Profiling server started at http://%s/debug/pprof/\n"), profileAddress) } + // Load configuration before logger setup so logging.output is respected. + cfg, err := config.Load(configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) + os.Exit(1) + } + // Setup logging - lcfg := buildLoggerConfig() + lcfg := buildLoggerConfig(cfg) logInstance, styledLogger, cleanup, err := logger.NewWithTheme(lcfg) if err != nil { fmt.Fprintf(os.Stderr, "Failed to initialise logger: %v\n", err) @@ -106,12 +115,6 @@ func main() { cancel() }() - // Load configuration - cfg, err := config.Load(configFile) - if err != nil { - logger.FatalWithLogger(logInstance, "Failed to load configuration", "error", err) - } - // Validate model alias configuration at startup if err = cfg.ValidateModelAliases(); err != nil { logger.FatalWithLogger(logInstance, "Invalid model alias configuration", "error", err) @@ -202,8 +205,8 @@ func reportProcessStats(logger logger.StyledLogger, startTime time.Time) { ) } -func buildLoggerConfig() *logger.Config { - return &logger.Config{ +func buildLoggerConfig(cfg *config.Config) *logger.Config { + lcfg := &logger.Config{ Level: env.GetEnvOrDefault("OLLA_LOG_LEVEL", DefaultLoggerLevel), PrettyLogs: env.GetEnvBoolOrDefault("OLLA_PRETTY_LOGS", DefaultPrettyLogs), FileOutput: env.GetEnvBoolOrDefault("OLLA_FILE_OUTPUT", DefaultFileOutput), @@ -213,4 +216,33 @@ func buildLoggerConfig() *logger.Config { MaxAge: env.GetEnvIntOrDefault("OLLA_LOG_MAX_AGE_DAYS", DefaultLogMaxAgeDays), Theme: env.GetEnvOrDefault("OLLA_THEME", DefaultTheme), } + + if cfg == nil { + return lcfg + } + + if cfg.Logging.Level != "" { + lcfg.Level = cfg.Logging.Level + } + + output := strings.TrimSpace(cfg.Logging.Output) + switch strings.ToLower(output) { + case "", "stdout": + lcfg.FileOutput = false + lcfg.OutputPath = "" + case "stderr": + lcfg.FileOutput = false + lcfg.OutputPath = "" + default: + lcfg.FileOutput = true + if filepath.IsAbs(output) { + lcfg.OutputPath = output + lcfg.LogDir = filepath.Dir(output) + } else { + lcfg.OutputPath = filepath.Clean(output) + lcfg.LogDir = filepath.Dir(lcfg.OutputPath) + } + } + + return lcfg }