diff --git a/cmd/otto/main.go b/cmd/otto/main.go new file mode 100644 index 0000000..812b250 --- /dev/null +++ b/cmd/otto/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/rustyeddy/otto/logging" + "github.com/spf13/cobra" +) + +const serverAddr = ":8011" + +var ( + logLevel string + logFormat string + logOutput string + logFile string +) + +var rootCmd = &cobra.Command{ + Use: "otto", + Short: "OttO IoT server", + SilenceUsage: true, + SilenceErrors: true, +} + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the HTTP server", + RunE: runServe, +} + +func init() { + serveCmd.Flags().StringVar(&logLevel, "log-level", logging.DefaultLevel, "Log level (debug, info, warn, error)") + serveCmd.Flags().StringVar(&logFormat, "log-format", logging.DefaultFormat, "Log format (text, json)") + serveCmd.Flags().StringVar(&logOutput, "log-output", logging.DefaultOutput, "Log output (stdout, stderr, file, string)") + serveCmd.Flags().StringVar(&logFile, "log-file", "", "Log file path (required when log-output=file)") + rootCmd.AddCommand(serveCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + slog.Error("command failed", "error", err) + os.Exit(1) + } +} + +func runServe(cmd *cobra.Command, args []string) error { + if strings.EqualFold(logOutput, "file") && strings.TrimSpace(logFile) == "" { + return errors.New("log-output=file requires --log-file") + } + + cfg := logging.Config{ + Level: logLevel, + Format: logFormat, + Output: logOutput, + FilePath: logFile, + } + + logService, err := logging.NewService(cfg) + if err != nil { + return err + } + + mux := http.NewServeMux() + mux.Handle("/api/log", logService) + + server := &http.Server{ + Addr: serverAddr, + Handler: mux, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { + errCh <- server.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return err + } + return nil + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + } +} diff --git a/logging/build.go b/logging/build.go new file mode 100644 index 0000000..0790e35 --- /dev/null +++ b/logging/build.go @@ -0,0 +1,93 @@ +package logging + +import ( + "bytes" + "fmt" + "io" + "log/slog" + "os" + "strings" +) + +// ParseLevel converts a string into a slog.Level. +func ParseLevel(s string) (slog.Level, error) { + value := strings.ToLower(strings.TrimSpace(s)) + if value == "warning" { + value = "warn" + } + + switch value { + case "debug": + return slog.LevelDebug, nil + case "info": + return slog.LevelInfo, nil + case "warn": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return slog.LevelInfo, fmt.Errorf("unsupported level %q", s) + } +} + +// Build builds a slog.Logger using the provided configuration. +func Build(cfg Config) (*slog.Logger, io.Closer, *bytes.Buffer, error) { + cfg, err := normalizeConfig(cfg) + if err != nil { + return nil, nil, nil, err + } + + level, err := ParseLevel(cfg.Level) + if err != nil { + return nil, nil, nil, err + } + + var ( + writer io.Writer + closer io.Closer + buf *bytes.Buffer + ) + + switch cfg.Output { + case "stdout": + writer = os.Stdout + case "stderr": + writer = os.Stderr + case "file": + file, err := os.OpenFile(cfg.FilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, nil, nil, fmt.Errorf("open log file: %w", err) + } + writer = file + closer = file + case "string": + if cfg.Buffer != nil { + buf = cfg.Buffer + } else { + buf = &bytes.Buffer{} + } + writer = buf + default: + return nil, nil, nil, fmt.Errorf("unsupported output %q", cfg.Output) + } + + opts := &slog.HandlerOptions{Level: level} + var handler slog.Handler + if cfg.Format == "json" { + handler = slog.NewJSONHandler(writer, opts) + } else { + handler = slog.NewTextHandler(writer, opts) + } + + logger := slog.New(handler) + return logger, closer, buf, nil +} + +// ApplyGlobal applies the logger and level to slog defaults. +func ApplyGlobal(logger *slog.Logger, level slog.Level) { + if logger == nil { + return + } + slog.SetDefault(logger) + slog.SetLogLoggerLevel(level) +} diff --git a/logging/config.go b/logging/config.go new file mode 100644 index 0000000..befaf8f --- /dev/null +++ b/logging/config.go @@ -0,0 +1,91 @@ +package logging + +import ( + "bytes" + "fmt" + "strings" +) + +const ( + DefaultLevel = "info" + DefaultFormat = "text" + DefaultOutput = "stdout" +) + +// Config defines configuration for logging outputs and formatting. +type Config struct { + Level string `json:"level"` + Format string `json:"format"` + Output string `json:"output"` + FilePath string `json:"filePath,omitempty"` + Buffer *bytes.Buffer `json:"-"` +} + +// DefaultConfig returns the default logging configuration. +func DefaultConfig() Config { + return Config{ + Level: DefaultLevel, + Format: DefaultFormat, + Output: DefaultOutput, + } +} + +// WithDefaults fills in empty fields with defaults. +func (c Config) WithDefaults() Config { + if strings.TrimSpace(c.Level) == "" { + c.Level = DefaultLevel + } + if strings.TrimSpace(c.Format) == "" { + c.Format = DefaultFormat + } + if strings.TrimSpace(c.Output) == "" { + c.Output = DefaultOutput + } + return c +} + +// Normalize lowercases string fields and clears file/buffer fields when not used. +func (c Config) Normalize() Config { + c.Level = strings.ToLower(strings.TrimSpace(c.Level)) + c.Format = strings.ToLower(strings.TrimSpace(c.Format)) + c.Output = strings.ToLower(strings.TrimSpace(c.Output)) + if c.Output != "file" { + c.FilePath = "" + } + if c.Output != "string" { + c.Buffer = nil + } + return c +} + +// Validate checks the configuration for supported values. +func (c Config) Validate() error { + if _, err := ParseLevel(c.Level); err != nil { + return err + } + + switch c.Format { + case "text", "json": + default: + return fmt.Errorf("unsupported format %q", c.Format) + } + + switch c.Output { + case "stdout", "stderr", "file", "string": + default: + return fmt.Errorf("unsupported output %q", c.Output) + } + + if c.Output == "file" && strings.TrimSpace(c.FilePath) == "" { + return fmt.Errorf("file output requires filePath") + } + return nil +} + +func normalizeConfig(cfg Config) (Config, error) { + cfg = cfg.WithDefaults().Normalize() + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} diff --git a/logging/logging_test.go b/logging/logging_test.go new file mode 100644 index 0000000..51a9d20 --- /dev/null +++ b/logging/logging_test.go @@ -0,0 +1,150 @@ +package logging + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseLevel_CaseInsensitive(t *testing.T) { + cases := []struct { + input string + level string + }{ + {input: "DEBUG", level: "debug"}, + {input: "Info", level: "info"}, + {input: "warn", level: "warn"}, + {input: "WARNING", level: "warn"}, + {input: "error", level: "error"}, + } + + for _, tc := range cases { + level, err := ParseLevel(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.level, level.String()) + } +} + +func TestBuild_OutputString_ReturnsBuffer(t *testing.T) { + cfg := Config{ + Level: "info", + Format: "text", + Output: "string", + } + + logger, closer, buf, err := Build(cfg) + require.NoError(t, err) + require.NotNil(t, logger) + assert.Nil(t, closer) + require.NotNil(t, buf) + + logger.Info("hello") + assert.NotEmpty(t, buf.String()) +} + +func TestBuild_OutputFile_ReturnsCloser(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "otto.log") + cfg := Config{ + Level: "info", + Format: "text", + Output: "file", + FilePath: path, + } + + logger, closer, buf, err := Build(cfg) + require.NoError(t, err) + require.NotNil(t, logger) + require.NotNil(t, closer) + assert.Nil(t, buf) + + logger.Info("hello") + require.NoError(t, closer.Close()) + + contents, err := os.ReadFile(path) + require.NoError(t, err) + assert.NotEmpty(t, contents) +} + +func TestService_HTTP_GET_ReturnsConfig(t *testing.T) { + svc, err := NewService(DefaultConfig()) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/log", nil) + rec := httptest.NewRecorder() + + svc.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var cfg Config + require.NoError(t, json.NewDecoder(rec.Body).Decode(&cfg)) + assert.Equal(t, DefaultLevel, cfg.Level) + assert.Equal(t, DefaultFormat, cfg.Format) + assert.Equal(t, DefaultOutput, cfg.Output) +} + +func TestService_HTTP_PUT_ValidConfig_Updates(t *testing.T) { + svc, err := NewService(DefaultConfig()) + require.NoError(t, err) + + payload := Config{ + Level: "DEBUG", + Format: "json", + Output: "string", + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPut, "/api/log", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + svc.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var cfg Config + require.NoError(t, json.NewDecoder(rec.Body).Decode(&cfg)) + assert.Equal(t, "debug", cfg.Level) + assert.Equal(t, "json", cfg.Format) + assert.Equal(t, "string", cfg.Output) + + current := svc.Config() + assert.Equal(t, "debug", current.Level) + assert.Equal(t, "json", current.Format) + assert.Equal(t, "string", current.Output) + assert.NotNil(t, current.Buffer) +} + +func TestService_HTTP_PUT_InvalidConfig_400(t *testing.T) { + svc, err := NewService(DefaultConfig()) + require.NoError(t, err) + + payload := Config{ + Level: "info", + Format: "text", + Output: "file", + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPut, "/api/log", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + svc.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var resp struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.NotEmpty(t, resp.Error) +} diff --git a/logging/service.go b/logging/service.go new file mode 100644 index 0000000..7792793 --- /dev/null +++ b/logging/service.go @@ -0,0 +1,101 @@ +package logging + +import ( + "encoding/json" + "io" + "net/http" + "sync" +) + +// Service manages the logging configuration and exposes an HTTP API. +type Service struct { + mu sync.Mutex + cfg Config + closer io.Closer +} + +// NewService creates a new Service and applies the configuration. +func NewService(cfg Config) (*Service, error) { + svc := &Service{} + if err := svc.SetConfig(cfg); err != nil { + return nil, err + } + return svc, nil +} + +// Config returns the current configuration. +func (s *Service) Config() Config { + s.mu.Lock() + defer s.mu.Unlock() + return s.cfg +} + +// SetConfig applies a new configuration and updates the global logger. +func (s *Service) SetConfig(cfg Config) error { + cfg, err := normalizeConfig(cfg) + if err != nil { + return err + } + + level, err := ParseLevel(cfg.Level) + if err != nil { + return err + } + + logger, closer, buf, err := Build(cfg) + if err != nil { + return err + } + + ApplyGlobal(logger, level) + + s.mu.Lock() + oldCloser := s.closer + cfg.Buffer = buf + if cfg.Output != "string" { + cfg.Buffer = nil + } + s.cfg = cfg + s.closer = closer + s.mu.Unlock() + + if oldCloser != nil { + _ = oldCloser.Close() + } + + return nil +} + +// ServeHTTP serves the logging configuration endpoint. +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cfg := s.Config() + writeJSON(w, http.StatusOK, cfg) + case http.MethodPut: + var cfg Config + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := s.SetConfig(cfg); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, s.Config()) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, struct { + Error string `json:"error"` + }{Error: err.Error()}) +}