diff --git a/.gitignore b/.gitignore index 5b90e79..c480041 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,13 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib - -# Test binary, built with `go test -c` *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file go.work go.work.sum - -# env file .env +.idea +/.dea/ diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..c9db740 --- /dev/null +++ b/config.toml @@ -0,0 +1,14 @@ +[log] +level = "info" +directory = "logs/" +rotation = 3 # in days + +[gateway] +http_port = "" +websocket = "" + +[database] +host_dsn = "" +username = "" +password = "" +database = "" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f2b8ec3 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module homestead/homestead_gateway + +go 1.25.4 + +require ( + github.com/pelletier/go-toml/v2 v2.2.4 + github.com/samber/slog-multi v1.6.0 +) + +require ( + github.com/samber/lo v1.52.0 // indirect + github.com/samber/slog-common v0.19.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b3a66c9 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI= +github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M= +github.com/samber/slog-multi v1.6.0 h1:i1uBY+aaln6ljwdf7Nrt4Sys8Kk6htuYuXDHWJsHtZg= +github.com/samber/slog-multi v1.6.0/go.mod h1:qTqzmKdPpT0h4PFsTN5rYRgLwom1v+fNGuIrl1Xnnts= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..116a53b --- /dev/null +++ b/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "flag" + "fmt" + "homestead/homestead_gateway/util/config" + "homestead/homestead_gateway/util/logger" + "log/slog" + "os" +) + +func main() { + cfgPath := flag.String("config", "config.toml", "configuration file") + + cfg, err := config.LoadConfig(*cfgPath) + if err != nil { + panic(err) + } + + l, closeFn, err := logger.New("my-service", cfg.Log) + if err != nil { + panic(err) + } + + defer func() { + if err := closeFn(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error closing logs: %v\n", err) + } + }() + + slog.SetDefault(l) + l.Info("started", "port", 8080) + l.Error("something broke", "err", err) +} diff --git a/util/config/structs.go b/util/config/structs.go new file mode 100644 index 0000000..0491f08 --- /dev/null +++ b/util/config/structs.go @@ -0,0 +1,27 @@ +package config + +import "log/slog" + +type Config struct { + Log LogConfig `toml:"log"` + Gateway GatewayConfig `toml:"gateway"` + Database DatabaseConfig `toml:"database"` +} + +type GatewayConfig struct { + HttpPort string `toml:"http_port"` + Websocket string `toml:"websocket"` +} + +type LogConfig struct { + Level slog.Level `toml:"level"` + Directory string `toml:"directory"` + Rotation int `toml:"rotation"` +} + +type DatabaseConfig struct { + HostDSN string `toml:"host_dsn"` + Username string `toml:"username"` + Password string `toml:"password"` + Database string `toml:"database"` +} diff --git a/util/config/toml.go b/util/config/toml.go new file mode 100644 index 0000000..a3351bf --- /dev/null +++ b/util/config/toml.go @@ -0,0 +1,21 @@ +package config + +import ( + "fmt" + "os" + + "github.com/pelletier/go-toml/v2" +) + +func LoadConfig(path string) (*Config, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open config: %w", err) + } + + var cfg Config + if err = toml.NewDecoder(file).Decode(&cfg); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/util/logger/log.go b/util/logger/log.go new file mode 100644 index 0000000..72f7a71 --- /dev/null +++ b/util/logger/log.go @@ -0,0 +1,191 @@ +package logger + +import ( + "context" + "fmt" + "homestead/homestead_gateway/util/config" + "log/slog" + "os" + "path/filepath" + "time" + + slogmulti "github.com/samber/slog-multi" +) + +// New creates a logger identified by id. +// Returns: *slog.Logger, closeFunc, error. +// Call closeFunc() on shutdown to close open files. +func New(id string, cfg config.LogConfig) (*slog.Logger, func() error, error) { + if cfg.Directory == "" { + cfg.Directory = "logs" + } + if cfg.Rotation <= 0 { + cfg.Rotation = 7 + } + + console := slog.NewTextHandler(&prefixWriter{inner: os.Stderr, prefix: []byte("[" + id + "] "), startLine: true}, &slog.HandlerOptions{AddSource: true}) + router := newFileRouter(cfg.Directory, cfg.Rotation, id) + root := slogmulti.Fanout(console, router) + + return slog.New(root), router.CloseFiles, nil +} + +func (p *prefixWriter) Write(b []byte) (int, error) { + p.mu.Lock() + defer p.mu.Unlock() + + totalWritten := 0 + if p.startLine { + n, err := p.inner.Write(p.prefix) + totalWritten += n + if err != nil { + return totalWritten, err + } + p.startLine = false + } + + n, err := p.inner.Write(b) + totalWritten += n + if err != nil { + return totalWritten, err + } + + if len(b) > 0 && b[len(b)-1] == '\n' { + p.startLine = true + } + return totalWritten, nil +} + +func newFileRouter(baseDir string, rotationDays int, id string) *fileRouter { + return &fileRouter{ + handlers: make(map[string]slog.Handler), + files: make(map[string]*os.File), + baseDir: baseDir, + rotationDays: rotationDays, + id: id, + dirTimeLayout: "2006-01-02", // e.g. 2025-11-30 + } +} + +//goland:noinspection GoUnusedParameter +func (r *fileRouter) Enabled(ctx context.Context, lvl slog.Level) bool { + // Conservatively true; actual handler will decide. + return true +} + +func (r *fileRouter) Handle(ctx context.Context, rec slog.Record) error { + now := time.Now() + dirName := now.Format(r.dirTimeLayout) + dirPath := filepath.Join(r.baseDir, dirName) + filePath := filepath.Join(dirPath, r.id+".log") + + h, err := r.getHandler(dirPath, filePath) + if err != nil { + return fmt.Errorf("file router get handler: %w", err) + } + + return h.Handle(ctx, rec) +} + +func (r *fileRouter) WithAttrs(attrs []slog.Attr) slog.Handler { + return r +} + +func (r *fileRouter) WithGroup(name string) slog.Handler { + return r +} + +// getHandler returns a text handler for the given file, creating dir/file and cleaning up old dirs if needed. +func (r *fileRouter) getHandler(dirPath, filePath string) (slog.Handler, error) { + r.mu.RLock() + h, ok := r.handlers[filePath] + r.mu.RUnlock() + if ok { + return h, nil + } + + r.mu.Lock() + defer r.mu.Unlock() + if h, ok = r.handlers[filePath]; ok { + return h, nil + } + + _, statErr := os.Stat(dirPath) + dirExisted := statErr == nil + + if err := os.MkdirAll(dirPath, 0o755); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", dirPath, err) + } + + if !dirExisted { + if err := r.cleanupOldDirs(); err != nil { + // don't fail logging just because cleanup failed; report to stderr and continue. + _, _ = fmt.Fprintf(os.Stderr, "logger: cleanupOldDirs error: %v\n", err) + } + } + + f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("open log file %s: %w", filePath, err) + } + + textHandler := slog.NewTextHandler(f, &slog.HandlerOptions{ + AddSource: false, + }) + + r.files[filePath] = f + r.handlers[filePath] = textHandler + return textHandler, nil +} + +// cleanupOldDirs scans r.baseDir for directories matching the dirTimeLayout and deletes any whose +// day-start time is older than rotationDays from now. +func (r *fileRouter) cleanupOldDirs() error { + entries, err := os.ReadDir(r.baseDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read baseDir: %w", err) + } + + now := time.Now() + cutoff := now.AddDate(0, 0, -r.rotationDays) + + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + t, err := time.ParseInLocation(r.dirTimeLayout, name, time.Local) + if err != nil { + continue + } + + if t.Before(cutoff) { + path := filepath.Join(r.baseDir, name) + + if err := os.RemoveAll(path); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "logger: failed to remove old log dir %s: %v\n", path, err) + } + } + } + + return nil +} + +// CloseFiles closes open files +func (r *fileRouter) CloseFiles() error { + r.mu.Lock() + defer r.mu.Unlock() + + var firstErr error + for p, f := range r.files { + if err := f.Close(); err != nil && firstErr == nil { + firstErr = err + } + delete(r.files, p) + delete(r.handlers, p) + } + return firstErr +} diff --git a/util/logger/structs.go b/util/logger/structs.go new file mode 100644 index 0000000..b9b8f2f --- /dev/null +++ b/util/logger/structs.go @@ -0,0 +1,27 @@ +package logger + +import ( + "io" + "log/slog" + "os" + "sync" +) + +type fileRouter struct { + mu sync.RWMutex + handlers map[string]slog.Handler // map[filePath]handler + files map[string]*os.File // map[filePath]*os.File to close later + baseDir string + rotationDays int + id string + dirTimeLayout string // "2006-01-02" - daily dirs +} + +// prefixWriter - writes a prefix at the start of each new line. +// It is safe for concurrent use. +type prefixWriter struct { + inner io.Writer + prefix []byte + mu sync.Mutex + startLine bool +}