From 1670bd6cf97d6bc321bbe539f26cf66158d5a75e Mon Sep 17 00:00:00 2001 From: cb0s Date: Thu, 21 Dec 2023 20:19:05 +0100 Subject: [PATCH] feat: add lib.go with config parsing and nats setup --- backend-go/go.mod | 13 ++ backend-go/go.sum | 14 ++ backend-go/lib.go | 324 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 backend-go/go.mod create mode 100644 backend-go/go.sum create mode 100644 backend-go/lib.go diff --git a/backend-go/go.mod b/backend-go/go.mod new file mode 100644 index 00000000..5c3daeeb --- /dev/null +++ b/backend-go/go.mod @@ -0,0 +1,13 @@ +module github.com/wuespace/telestion/backend + +go 1.21.4 + +require ( + github.com/klauspost/compress v1.17.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/nats-io/nats.go v1.31.0 // indirect + github.com/nats-io/nkeys v0.4.5 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/sys v0.5.0 // indirect +) diff --git a/backend-go/go.sum b/backend-go/go.sum new file mode 100644 index 00000000..e30f28bd --- /dev/null +++ b/backend-go/go.sum @@ -0,0 +1,14 @@ +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= +github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= +github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk= +github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend-go/lib.go b/backend-go/lib.go new file mode 100644 index 00000000..a95fed0d --- /dev/null +++ b/backend-go/lib.go @@ -0,0 +1,324 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "github.com/mitchellh/mapstructure" + "github.com/nats-io/nats.go" + "log" + "os" + "path/filepath" + "strings" +) + +var Config TelestionBaseConfig + +type TelestionError struct { + Error *error // only present if the TelestionError is based on an error + Message string + Code int // might be interesting later if we want to identify where the error is coming from +} + +func (t *TelestionError) FormatError() string { + return fmt.Sprint( + "An unexpected error (code: ", + t.Code, + ") occurred in the Telestion backend library: \"", + t.Message, + "\"") +} + +func NewTelestionError(err error, msg string) *TelestionError { + return &TelestionError{&err, msg, 1} +} + +// Config parsing process must at least yield the following minimal config scheme +type minimalConfig struct { + Nats bool `json:"NATS"` + NatsUrl string `json:"NATS_URL"` + ServiceName string `json:"SERVICE_NAME"` + DataDir string `json:"DATA_DIR"` +} + +func inferMinimalConfig(mapping map[string]any) (*minimalConfig, *TelestionError) { + mConf := minimalConfig{} + + decoderConfig := &mapstructure.DecoderConfig{ + ErrorUnused: false, + ErrorUnset: true, + Result: &mConf, + } + mDecoder, err := mapstructure.NewDecoder(decoderConfig) + if err != nil { + return nil, NewTelestionError(err, "Decoder for minimal config inference could not be initialized!") + } + + if err := mDecoder.Decode(mapping); err != nil { + return nil, NewTelestionError(err, "Minimal config could not be inferred from given map!") + } + + return &mConf, nil +} + +// Type of config that is returned after parsing +type TelestionBaseConfig struct { + Dev bool `json:"DEV"` + Nats bool `json:"NATS"` + NatsUrl string `json:"NATS_URL"` + NatsUsr string `json:"NATS_USER"` + NatsPwd string `json:"NATS_PASSWORD"` + ConfigFile string `json:"CONFIG_FILE"` + ConfigKey string `json:"CONFIG_KEY"` + ServiceName string `json:"SERVICE_NAME"` + DataDir string `json:"DATA_DIR"` + NonFlagArgs []string `json:"NON_FLAG_ARGS"` + + CustomConfig map[string]any `json:"-" ,mapstructure:",remain"` +} + +func inferTelestionConfig(mapping map[string]any) (*TelestionBaseConfig, *TelestionError) { + tConfig := TelestionBaseConfig{} + + decoderConfig := &mapstructure.DecoderConfig{ + ErrorUnused: false, + ErrorUnset: false, + WeaklyTypedInput: true, + Result: tConfig, + } + + tDecoder, err := mapstructure.NewDecoder(decoderConfig) + if err != nil { + return nil, NewTelestionError(err, "Decoder for TelestionBaseConfig inference could not be initialized!") + } + + if err := tDecoder.Decode(mapping); err != nil { + return nil, NewTelestionError(err, "TelestionBaseConfig could not be inferred from given map!") + } + + return &tConfig, nil +} + +func StartService() (*nats.Conn, *TelestionError) { + // Ensure the config contains the minimum required fields + configMapping := loadConfig() + if _, err := inferMinimalConfig(configMapping); err != nil { + return nil, err + } + + // Parse config into usable data format + cfgP, err := inferTelestionConfig(configMapping) + Config = *cfgP + if err != nil { + return nil, err + } + + // Init NATS + if Config.Nats { + return initializeNats() + } else { + return nil, NewTelestionError(nil, + "Initializing a telestion service without NATS is currently not possible! "+ + "To continue set the corresponding NATS flag to true again.") + } +} + +func loadConfig() map[string]any { + config := map[string]any{ + "CONFIG_FILE": nil, + "CONFIG_KEY": nil, + } + updateConfig(config, parseConsoleArguments()) + populateConfig(config, getEnvs()) + if _, contained := config["DEV"]; !contained { + config["DEV"] = false + } + + populateConfig(config, getDefaultConfig(config["DEV"].(bool))) + populateConfig(config, getFileConfig(config)) + return config +} + +func updateConfig[V any | string](config map[string]any, updates map[string]V) { + for k, v := range updates { + config[k] = v + } +} + +func populateConfig[V any | string](config map[string]any, updates map[string]V) { + for k, v := range updates { + // check if key is already in map and populate if it isn't + if _, contained := config[k]; !contained { + config[k] = v + } + } +} + +func getFileConfig(config map[string]any) map[string]any { + // Note that the file config is supposed to be a json config! + jsonConfig := map[string]any{} + + configPath := config["CONFIG_FILE"].(string) + if len(configPath) == 0 { + return jsonConfig + } + + jsonConfigBytes, err := os.ReadFile(configPath) + + if err != nil { + log.Printf("Config file %s could not be read: %s\n", configPath, err) + return jsonConfig + } + + err = json.Unmarshal(jsonConfigBytes, &jsonConfig) + if err != nil { + log.Printf("Config file %s could not be parsed: %s\n", configPath, err) + return jsonConfig + } + + return jsonConfig +} + +func getDefaultConfig(devFlag bool) map[string]string { + if !devFlag { + return map[string]string{ + "NATS": "true", // at the moment only nats is supported + } + } + + log.Println("Running in development mode. Using default values for missing environment variables.") + return map[string]string{ + "NATS": "true", // at the moment only nats is supported + "NATS_URL": nats.DefaultURL, + "SERVICE_NAME": fmt.Sprint("dev-", os.Getgid()), + "DATA_DIR": filepath.Join(".", "data"), // we use filepath here to get the correct separator + } +} + +func getEnvs() map[string]string { + envs := map[string]string{} + for _, entry := range os.Environ() { + split := strings.Split(entry, "=") + key, val := split[0], split[1] + + // we don't want to overwrite config entries with empty environment variables! + if len(val) == 0 { + continue + } + + envs[key] = val + } + return envs +} + +func parseConsoleArguments() map[string]any { + // setup flags + var ( + devFlag bool + natsFlag bool + + natsUrlFlag string + natsUserFlag string + natsPwdFlag string + + configFileFlag string + configKeyFlag string + + serviceNameFlag string + dataDirFlag string + ) + + warnDescription := "(Note: It is recommended to set this via the environment variables or the config!)" + + flag.BoolVar(&devFlag, "development mode", false, "If set, program will start in development mode") + flag.BoolVar(&natsFlag, "use NATS", true, "If set, service will use NATS as the messaging bus (default)") + + flag.StringVar(&natsUrlFlag, "NATS url", "", "NATS url of the server the service can connect to") + flag.StringVar(&natsUserFlag, "NATS user", "", "NATS user name for the authentication with the server") + flag.StringVar(&natsPwdFlag, "NATS password", "", "NATS password for the authentication with the server "+ + warnDescription) + + flag.StringVar(&configFileFlag, "config file path", "", "file path to the config of the service") + flag.StringVar(&configKeyFlag, "config file password", "", "password of the config file "+ + warnDescription) + + flag.StringVar(&serviceNameFlag, "service name", "", "") + flag.StringVar(&dataDirFlag, "data directory path", "", "") + + // we don't really like the default message of the flag package + flag.Usage = func() { + fmt.Printf("Usage: %s [options] [field_0 ... field_n]\n\nParameters:\n", os.Args[0]) + flag.PrintDefaults() + } + flag.Parse() + + // additional arguments that can be used by other parts of the service (although they should register flags + // themselves before calling the initialize method if possible! -> makes parsing a lot easier + only one behaviour + // can be mimicked by multiple non-flag arguments) + otherArgs := flag.Args() + + // prepare output map + parsedArgs := map[string]any{ + "NON_FLAG_ARGS": otherArgs, + } + + /// populate parsedArgs with entries that were indeed given + for k, v := range map[string]any{ + "DEV": devFlag, + "NATS": natsFlag, + "NATS_URL": natsUrlFlag, + "NATS_USER": natsUserFlag, + "NATS_PASSWORD": natsPwdFlag, + "CONFIG_FILE": configFileFlag, + "CONFIG_KEY": configKeyFlag, + "SERVICE_NAME": serviceNameFlag, + "DATA_DIR": dataDirFlag, + } { + if isFlagPassed(k) { + parsedArgs[k] = v + } + } + + return parsedArgs +} + +func isFlagPassed(name string) bool { + passed := false + flag.Visit(func(f *flag.Flag) { + if f.Name == name { + passed = true + } + }) + return passed +} + +func initializeNats() (*nats.Conn, *TelestionError) { + var userInfo nats.Option = nil + if len(Config.NatsUsr) != 0 && len(Config.NatsPwd) != 0 { + userInfo = nats.UserInfo(Config.NatsUsr, Config.NatsPwd) + } + + nc, err := nats.Connect(Config.NatsUrl, userInfo) + + if err != nil { + return nil, NewTelestionError(err, fmt.Sprintf("Nats client could not be connected for %s!", + Config.ServiceName)) + } + + // Register health check + message := fmt.Sprintf(`{"name":"%s"}`, Config.ServiceName) // set here for not needing to reevaluate each time + _, err = nc.Subscribe("__telestion__.health", func(m *nats.Msg) { + err := m.Respond([]byte(message)) + if err != nil { + // TODO: is Panicln better here? + log.Println("Health message message of ", Config.ServiceName, " could not be sent!") + } + }) + + if err != nil { + return nil, NewTelestionError(err, + fmt.Sprintf("__telestion__.health message could not be registered for %s!", Config.ServiceName)) + } + + return nc, nil +}