go: preparing the CLI interface

This commit is contained in:
Tigor Hutasuhut 2024-08-11 19:50:43 +07:00
parent b94b9e7d20
commit c0db85e85f
7 changed files with 157 additions and 60 deletions

1
go.mod
View file

@ -14,6 +14,7 @@ require (
github.com/go-faster/errors v0.7.1 github.com/go-faster/errors v0.7.1
github.com/go-faster/jx v1.1.0 github.com/go-faster/jx v1.1.0
github.com/jaswdr/faker/v2 v2.3.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/json v0.1.0
github.com/knadh/koanf/parsers/yaml v0.1.0 github.com/knadh/koanf/parsers/yaml v0.1.0
github.com/knadh/koanf/providers/confmap v0.1.0 github.com/knadh/koanf/providers/confmap v0.1.0

2
go.sum
View file

@ -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/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 h1:jgQ9UmU2Eb5tSQ8JkUS4tPoyTM2OtThQpOpwk7Fa9RY=
github.com/jaswdr/faker/v2 v2.3.0/go.mod h1:ROK8xwQV0hYOLDUtxCQgHGcl10jbVzIvqHxcIDdwY2Q= 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 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU= github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU=

View file

@ -2,11 +2,16 @@ package main
import ( import (
"context" "context"
"fmt"
"log/slog"
"os" "os"
"os/signal" "os/signal"
"github.com/adrg/xdg"
"github.com/joho/godotenv"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tigorlazuardi/bluemage/go/cmd/bluemage/serve" "github.com/tigorlazuardi/bluemage/go/cmd/bluemage/serve"
"github.com/tigorlazuardi/bluemage/go/config"
) )
var Cmd = &cobra.Command{ var Cmd = &cobra.Command{
@ -16,12 +21,54 @@ var Cmd = &cobra.Command{
func init() { func init() {
Cmd.AddCommand(serve.Cmd) 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() { 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) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel() defer cancel()
ctx = config.WithContext(ctx, cfg)
if err := Cmd.ExecuteContext(ctx); err != nil { if err := Cmd.ExecuteContext(ctx); err != nil {
os.Exit(1) os.Exit(1)
} }

View file

@ -4,7 +4,9 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"os" "os"
"time" "time"
@ -15,6 +17,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stephenafamo/bob" "github.com/stephenafamo/bob"
"github.com/tigorlazuardi/bluemage/go/api" "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/gen/proto/device/v1/v1connect"
"github.com/tigorlazuardi/bluemage/go/pkg/errs" "github.com/tigorlazuardi/bluemage/go/pkg/errs"
"github.com/tigorlazuardi/bluemage/go/pkg/log" "github.com/tigorlazuardi/bluemage/go/pkg/log"
@ -26,9 +29,22 @@ import (
var Cmd = &cobra.Command{ var Cmd = &cobra.Command{
Use: "serve", Use: "serve",
RunE: func(cmd *cobra.Command, args []string) error { 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 { 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( sqldb = sqldblogger.OpenDriver(
@ -38,9 +54,6 @@ var Cmd = &cobra.Command{
sqldblogger.WithSQLQueryAsMessage(true), sqldblogger.WithSQLQueryAsMessage(true),
) )
db := bob.New(sqldb) db := bob.New(sqldb)
logOutput := log.WrapOsFile(os.Stderr)
prettyHandler := log.NewPrettyHandler(logOutput, nil)
slog.SetDefault(slog.New(prettyHandler))
api := &api.API{ api := &api.API{
DB: db, DB: db,
@ -64,12 +77,15 @@ var Cmd = &cobra.Command{
))) )))
server := &http.Server{ 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{}), Handler: h2c.NewHandler(server.WithCORS(mux), &http2.Server{}),
BaseContext: func(net.Listener) context.Context {
return ctx
},
} }
go func() { go func() {
<-cmd.Context().Done() <-ctx.Done()
slog.Info("Exit signal received. Shutting down server") slog.Info("Exit signal received. Shutting down server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel() defer cancel()
@ -86,3 +102,6 @@ var Cmd = &cobra.Command{
}, },
SilenceUsage: true, SilenceUsage: true,
} }
func Register(cmd *cobra.Command, config *config.Config) {
}

View file

@ -33,7 +33,7 @@ func (builder *ConfigBuilder) BuildHandle() (*Config, error) {
} }
func (builder *ConfigBuilder) LoadDefault() *ConfigBuilder { func (builder *ConfigBuilder) LoadDefault() *ConfigBuilder {
provider := confmap.Provider(DefaultConfig, ".") provider := confmap.Provider(DefaultConfig.ToMap(), ".")
err := builder.koanf.Load(provider, nil) err := builder.koanf.Load(provider, nil)
if err != nil { if err != nil {

20
go/config/context.go Normal file
View file

@ -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
}

View file

@ -2,61 +2,69 @@ package config
import ( import (
"path" "path"
"time"
"github.com/adrg/xdg" "github.com/adrg/xdg"
) )
var Version string = "unknown" var Version string = "unknown"
var DefaultConfig = map[string]any{ type Entry struct {
"flags.containerized": false, Key string
Value any
"log.enable": true, Desc string
"log.level": "info", Hidden bool
"log.output": "stderr", }
"log.file.enable": true,
"log.file.path": path.Join(xdg.CacheHome, "bluemage", "bluemage.log"), type Entries []Entry
"db.driver": "sqlite3", func (e Entries) ToMap() map[string]any {
"db.string": path.Join(xdg.Home, ".local", "share", "bluemage", "data.db"), m := make(map[string]any, len(e))
for _, entry := range e {
"pubsub.db.name": path.Join(xdg.Home, ".local", "share", "bluemage", "pubsub.db"), m[entry.Key] = entry.Value
"pubsub.db.timeout": "5s", }
"pubsub.ack.deadline": "30m", return m
}
"download.concurrency.images": 5,
"download.concurrency.subreddits": 3, var DefaultConfig = Entries{
{"log.file.enable", true, "Enable file logging", false},
"download.directory": path.Join(xdg.UserDirs.Pictures, "bluemage"), {"log.level", "info", `Log level. Possible values: "debug", "info", "warn", "error"`, false},
"download.timeout.headers": "10s", {"log.output", "stderr", "Log output. Possible values: \"stdout\", \"stderr\"", false},
"download.timeout.idle": "5s", {"log.file.path", path.Join(xdg.CacheHome, "bluemage", "bluemage.log"), "Log file path", false},
"download.timeout.idlespeed": "10KB",
"download.useragent": "bluemage/v1", {"db.driver", "sqlite3", "Database driver", false},
{"db.path", path.Join(xdg.Home, ".local", "share", "bluemage", "data.db"), "Database path", false},
"http.port": "8080",
"http.host": "0.0.0.0", {"pubsub.db.path", path.Join(xdg.Home, ".local", "share", "bluemage", "pubsub.db"), "PubSub database path", false},
"http.shutdown_timeout": "5s", {"pubsub.db.timeout", "5s", "PubSub database timeout", false},
{"pubsub.ack.deadline", "30m", "PubSub ack deadline", false},
"telemetry.openobserve.enable": false,
"telemetry.openobserve.log.enable": true, {"download.concurrency", 5, "Number of concurrent image downloads", false},
"telemetry.openobserve.log.level": "info", {"download.directory", path.Join(xdg.UserDirs.Pictures, "bluemage"), "Download directory", false},
"telemetry.openobserve.log.source": true, {"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},
"telemetry.openobserve.log.endpoint": "http://localhost:5080/api/default/default/_json", {"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},
"telemetry.openobserve.log.concurrency": 4, {"download.timeout.idlespeed", "10KB", "Threshold for the download speed to be considered 'idle'", false},
"telemetry.openobserve.log.buffer.size": 2 * 1024, // 2kb {"download.useragent", "bluemage/v1", "Download user agent. Must not be empty. Avoid common user agent values like 'curl'", false},
"telemetry.openobserve.log.buffer.timeout": "2s",
"telemetry.openobserve.log.username": "root@example.com", {"http.port", "8080", "HTTP server port", false},
"telemetry.openobserve.log.password": "Complexpass#123", {"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.trace.enable": true,
"telemetry.openobserve.trace.url": "http://localhost:5080/api/default/v1/traces", {"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.trace.auth": "Basic AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", {"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.trace.ratio": 1, {"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},
"runtime.version": Version, {"telemetry.openobserve.log.concurrency", 4, "Number of allowed concurrent connections to send logs to the server", false},
"runtime.environment": "development", {"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},
"scheduler.timeout": time.Second * 10, {"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},
} }