diff --git a/go.mod b/go.mod index ad7ff72..7860a15 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-faster/errors v0.7.1 github.com/go-faster/jx v1.1.0 github.com/jaswdr/faker/v2 v2.3.0 + github.com/joho/godotenv v1.5.1 github.com/knadh/koanf/parsers/json v0.1.0 github.com/knadh/koanf/parsers/yaml v0.1.0 github.com/knadh/koanf/providers/confmap v0.1.0 diff --git a/go.sum b/go.sum index 0ea5335..1b12fc7 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jaswdr/faker/v2 v2.3.0 h1:jgQ9UmU2Eb5tSQ8JkUS4tPoyTM2OtThQpOpwk7Fa9RY= github.com/jaswdr/faker/v2 v2.3.0/go.mod h1:ROK8xwQV0hYOLDUtxCQgHGcl10jbVzIvqHxcIDdwY2Q= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU= diff --git a/go/cmd/bluemage/main.go b/go/cmd/bluemage/main.go index 7514539..cd1b233 100644 --- a/go/cmd/bluemage/main.go +++ b/go/cmd/bluemage/main.go @@ -2,11 +2,16 @@ package main import ( "context" + "fmt" + "log/slog" "os" "os/signal" + "github.com/adrg/xdg" + "github.com/joho/godotenv" "github.com/spf13/cobra" "github.com/tigorlazuardi/bluemage/go/cmd/bluemage/serve" + "github.com/tigorlazuardi/bluemage/go/config" ) var Cmd = &cobra.Command{ @@ -16,12 +21,54 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(serve.Cmd) + + flags := Cmd.PersistentFlags() + for _, entry := range config.DefaultConfig { + key, value, desc, hidden := entry.Key, entry.Value, entry.Desc, entry.Hidden + switch v := value.(type) { + case bool: + flags.Bool(key, v, desc) + case string: + flags.String(key, v, desc) + case int: + flags.Int(key, v, desc) + case float32: + flags.Float32(key, v, desc) + case float64: + flags.Float64(key, v, desc) + default: + flags.String(key, fmt.Sprintf("%v", v), desc) + } + if hidden { + flags.MarkHidden(key) + } + } } func main() { + _ = godotenv.Load() + + xdgJson, _ := xdg.ConfigFile("bluemage/config.json") + xdgYaml, _ := xdg.ConfigFile("bluemage/config.yaml") + + cfg, err := config.NewConfigBuilder(). + LoadDefault(). + LoadJSONFile("/etc/bluemage/config.json"). + LoadYamlFile("/etc/bluemage/config.yaml"). + LoadJSONFile(xdgJson). + LoadYamlFile(xdgYaml). + LoadJSONFile("config.json"). + LoadYamlFile("config.yaml"). + LoadEnv(). + LoadFlags(Cmd.PersistentFlags()). + BuildHandle() + if err != nil { + slog.Error("BLUEMAGE: failed to load config", "error", err) + os.Exit(1) + } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() - + ctx = config.WithContext(ctx, cfg) if err := Cmd.ExecuteContext(ctx); err != nil { os.Exit(1) } diff --git a/go/cmd/bluemage/serve/serve.go b/go/cmd/bluemage/serve/serve.go index 055c488..e3e35a1 100644 --- a/go/cmd/bluemage/serve/serve.go +++ b/go/cmd/bluemage/serve/serve.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "errors" + "fmt" "log/slog" + "net" "net/http" "os" "time" @@ -15,6 +17,7 @@ import ( "github.com/spf13/cobra" "github.com/stephenafamo/bob" "github.com/tigorlazuardi/bluemage/go/api" + "github.com/tigorlazuardi/bluemage/go/config" "github.com/tigorlazuardi/bluemage/go/gen/proto/device/v1/v1connect" "github.com/tigorlazuardi/bluemage/go/pkg/errs" "github.com/tigorlazuardi/bluemage/go/pkg/log" @@ -26,9 +29,22 @@ import ( var Cmd = &cobra.Command{ Use: "serve", RunE: func(cmd *cobra.Command, args []string) error { - sqldb, err := sql.Open("sqlite3", "file:data.db") + ctx := cmd.Context() + cfg := config.FromContext(ctx) + logOutput := log.WrapOsFile(os.Stderr) + prettyHandler := log.NewPrettyHandler(logOutput, nil) + slog.SetDefault(slog.New(prettyHandler)) + + if err := os.MkdirAll(cfg.String("download.directory"), 0755); err != nil { + return errs.Wrapw(err, "failed to create download directory", "path", cfg.String("download.directory")) + } + + sqldb, err := sql.Open(cfg.String("db.driver"), fmt.Sprintf("file:%s", cfg.String("db.path"))) if err != nil { - return errs.Wrapw(err, "failed to open database", "file", "data.db") + return errs.Wrapw(err, "failed to open database", + "driver", cfg.String("db.driver"), + "dsn", cfg.String("db.dsn"), + ) } sqldb = sqldblogger.OpenDriver( @@ -38,9 +54,6 @@ var Cmd = &cobra.Command{ sqldblogger.WithSQLQueryAsMessage(true), ) db := bob.New(sqldb) - logOutput := log.WrapOsFile(os.Stderr) - prettyHandler := log.NewPrettyHandler(logOutput, nil) - slog.SetDefault(slog.New(prettyHandler)) api := &api.API{ DB: db, @@ -64,12 +77,15 @@ var Cmd = &cobra.Command{ ))) server := &http.Server{ - Addr: ":8080", + Addr: fmt.Sprintf("%s:%s", cfg.String("http.host"), cfg.String("http.port")), Handler: h2c.NewHandler(server.WithCORS(mux), &http2.Server{}), + BaseContext: func(net.Listener) context.Context { + return ctx + }, } go func() { - <-cmd.Context().Done() + <-ctx.Done() slog.Info("Exit signal received. Shutting down server") shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() @@ -86,3 +102,6 @@ var Cmd = &cobra.Command{ }, SilenceUsage: true, } + +func Register(cmd *cobra.Command, config *config.Config) { +} diff --git a/go/config/builder.go b/go/config/builder.go index e047486..9200c92 100644 --- a/go/config/builder.go +++ b/go/config/builder.go @@ -33,7 +33,7 @@ func (builder *ConfigBuilder) BuildHandle() (*Config, error) { } func (builder *ConfigBuilder) LoadDefault() *ConfigBuilder { - provider := confmap.Provider(DefaultConfig, ".") + provider := confmap.Provider(DefaultConfig.ToMap(), ".") err := builder.koanf.Load(provider, nil) if err != nil { diff --git a/go/config/context.go b/go/config/context.go new file mode 100644 index 0000000..5936124 --- /dev/null +++ b/go/config/context.go @@ -0,0 +1,20 @@ +package config + +import "context" + +type contextKey struct{} + +var key = contextKey{} + +// WithContext returns a new context with the given Config. +func WithContext(ctx context.Context, cfg *Config) context.Context { + return context.WithValue(ctx, key, cfg) +} + +// FromContext returns the Config from the given context. +// +// Returns nil if the Config is not found. +func FromContext(ctx context.Context) *Config { + cfg, _ := ctx.Value(key).(*Config) + return cfg +} diff --git a/go/config/default.go b/go/config/default.go index 7efa05b..58933e3 100644 --- a/go/config/default.go +++ b/go/config/default.go @@ -2,61 +2,69 @@ package config import ( "path" - "time" "github.com/adrg/xdg" ) var Version string = "unknown" -var DefaultConfig = map[string]any{ - "flags.containerized": false, - - "log.enable": true, - "log.level": "info", - "log.output": "stderr", - "log.file.enable": true, - "log.file.path": path.Join(xdg.CacheHome, "bluemage", "bluemage.log"), - - "db.driver": "sqlite3", - "db.string": path.Join(xdg.Home, ".local", "share", "bluemage", "data.db"), - - "pubsub.db.name": path.Join(xdg.Home, ".local", "share", "bluemage", "pubsub.db"), - "pubsub.db.timeout": "5s", - "pubsub.ack.deadline": "30m", - - "download.concurrency.images": 5, - "download.concurrency.subreddits": 3, - - "download.directory": path.Join(xdg.UserDirs.Pictures, "bluemage"), - "download.timeout.headers": "10s", - "download.timeout.idle": "5s", - "download.timeout.idlespeed": "10KB", - "download.useragent": "bluemage/v1", - - "http.port": "8080", - "http.host": "0.0.0.0", - "http.shutdown_timeout": "5s", - - "telemetry.openobserve.enable": false, - "telemetry.openobserve.log.enable": true, - "telemetry.openobserve.log.level": "info", - "telemetry.openobserve.log.source": true, - "telemetry.openobserve.log.endpoint": "http://localhost:5080/api/default/default/_json", - "telemetry.openobserve.log.concurrency": 4, - "telemetry.openobserve.log.buffer.size": 2 * 1024, // 2kb - "telemetry.openobserve.log.buffer.timeout": "2s", - "telemetry.openobserve.log.username": "root@example.com", - "telemetry.openobserve.log.password": "Complexpass#123", - - "telemetry.openobserve.trace.enable": true, - "telemetry.openobserve.trace.url": "http://localhost:5080/api/default/v1/traces", - "telemetry.openobserve.trace.auth": "Basic AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - - "telemetry.trace.ratio": 1, - - "runtime.version": Version, - "runtime.environment": "development", - - "scheduler.timeout": time.Second * 10, +type Entry struct { + Key string + Value any + Desc string + Hidden bool +} + +type Entries []Entry + +func (e Entries) ToMap() map[string]any { + m := make(map[string]any, len(e)) + for _, entry := range e { + m[entry.Key] = entry.Value + } + return m +} + +var DefaultConfig = Entries{ + {"log.file.enable", true, "Enable file logging", false}, + {"log.level", "info", `Log level. Possible values: "debug", "info", "warn", "error"`, false}, + {"log.output", "stderr", "Log output. Possible values: \"stdout\", \"stderr\"", false}, + {"log.file.path", path.Join(xdg.CacheHome, "bluemage", "bluemage.log"), "Log file path", false}, + + {"db.driver", "sqlite3", "Database driver", false}, + {"db.path", path.Join(xdg.Home, ".local", "share", "bluemage", "data.db"), "Database path", false}, + + {"pubsub.db.path", path.Join(xdg.Home, ".local", "share", "bluemage", "pubsub.db"), "PubSub database path", false}, + {"pubsub.db.timeout", "5s", "PubSub database timeout", false}, + {"pubsub.ack.deadline", "30m", "PubSub ack deadline", false}, + + {"download.concurrency", 5, "Number of concurrent image downloads", false}, + {"download.directory", path.Join(xdg.UserDirs.Pictures, "bluemage"), "Download directory", false}, + {"download.timeout.headers", "10s", "How long to wait for Reddit to response with data after opening connection. To prevent download connection getting clogged up for too long", false}, + {"download.timeout.idle", "5s", "Download idle timeout. Whenever a download speed is under idlespeed for this duration, the download is cancelled. To prevent slow download speed from clogging up the queue", false}, + {"download.timeout.idlespeed", "10KB", "Threshold for the download speed to be considered 'idle'", false}, + {"download.useragent", "bluemage/v1", "Download user agent. Must not be empty. Avoid common user agent values like 'curl'", false}, + + {"http.port", "8080", "HTTP server port", false}, + {"http.host", "0.0.0.0", "HTTP server host", false}, + {"http.shutdown_timeout", "5s", "Timeout to wait closing i/o connections until the server force closes everything", false}, + + {"telemetry.openobserve.enable", false, "Wether to enable telemetry support to OpenObserve server. Setting this to false, disables all other 'telemetry.openobserve' options (default false)", false}, + {"telemetry.openobserve.log.enable", true, "Wether to enable logging telemetry to OpenObserve server", false}, + {"telemetry.openobserve.log.level", "info", "Log level for telemetry logging", false}, + {"telemetry.openobserve.log.source", true, "Wether to include source code in the log", false}, + {"telemetry.openobserve.log.endpoint", "http://localhost:5080/api/default/default/_json", "OpenObserve log endpoint", false}, + {"telemetry.openobserve.log.concurrency", 4, "Number of allowed concurrent connections to send logs to the server", false}, + {"telemetry.openobserve.log.buffer.size", 2 * 1024, "Buffer size (in bytes) to wait before being sent to the server", false}, + {"telemetry.openobserve.log.buffer.timeout", "2s", "Timeout to send logs if the buffer is not empty but not yet filled", false}, + {"telemetry.openobserve.log.username", "root@example.com", "OpenObserve logging endpoint username", false}, + {"telemetry.openobserve.log.password", "Complexpass#123", "OpenObserve logging endpoint password", false}, + {"telemetry.openobserve.trace.enable", true, "Wether to enable sending tracing to OpenObserve server", false}, + {"telemetry.openobserve.trace.url", "http://localhost:5080/api/default/v1/traces", "Endpoint url to send traces to", false}, + {"telemetry.openobserve.trace.auth", "Basic AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "Authorization token for the Endpoint URL", false}, + {"telemetry.trace.ratio", float64(1), "Sampling ratio between 0 to 1 on how many traces are sent to the server. Value of 1 will send everything", false}, + + {"runtime.version", Version, "current server version", true}, + {"runtime.environment", "development", "runtime environment", true}, + {"flags.containerized", false, "Indicates the application is running in a container", true}, }