Skip to content

Commit

Permalink
Full rewrite of backend-go
Browse files Browse the repository at this point in the history
  • Loading branch information
pklaschka committed Dec 14, 2024
1 parent fd27c3f commit 8ed7943
Show file tree
Hide file tree
Showing 26 changed files with 424 additions and 911 deletions.
6 changes: 0 additions & 6 deletions backend-go/.nats.conf

This file was deleted.

26 changes: 19 additions & 7 deletions backend-go/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
FROM golang:1.21-alpine
# Use the official Golang image as the base image
FROM golang:1.23-alpine

# creates an "invisible" docker volume during container startup
# by retaining the go builc cache from the image build
# to support different cpu architectures
VOLUME /go

# switch to app
# Set the working directory inside the container
WORKDIR /app

# Copy the Go module files and download dependencies
COPY go.mod go.sum ./
RUN go mod download

# Copy the rest of the application code
COPY . .

# Build the Go application
RUN go build -o /testbed ./testbed/testbed.go

# Set the entrypoint to the built binary
ENTRYPOINT ["/testbed"]

# Default command (can be overridden by CMD in docker run)
CMD []
29 changes: 7 additions & 22 deletions backend-go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,43 +19,28 @@ go get -u github.com/wuespace/telestion/backend-go@latest
package main

import (
"github.com/wuespace/telestion/backend-go"
"log"
"github.com/wuespace/telestion/backend-go"
)

type Person struct {
Name string `json:"name"`
Address string `json:"address"`
}

func main() {
// start a new Telestion service
service, err := telestion.StartService()
if err != nil {
log.Fatal(err)
}
log.Println("Service started")

// publish a message on the message bus
service.Nc.Publish("my-topic", []byte("Hello from Go!"))

// subscribe to receive messages from the message bus
// automatically unmarshal JSON message to go struct
_, err = service.NcJson.Subscribe("registered-person-topic", func(person *Person) {
log.Println("Received new personal information:", person)
})
if err != nil {
log.Println(err)
}

service.Nc.Publish(service.Config["OUT"], []byte("Hello from Go!"))

// wait for interrupts to prevent immediate shutdown of service
telestion.WaitForInterrupt()

// drain remaining messages and close connection
if err1, err2 := service.Drain(); err1 != nil || err2 != nil {
log.Fatal(err1, err2)
}
service.Drain()
}

```

## Behavior Specification
Expand Down
274 changes: 73 additions & 201 deletions backend-go/config.go
Original file line number Diff line number Diff line change
@@ -1,232 +1,104 @@
package telestion

import (
"encoding/json"
"flag"
"fmt"
"github.com/mitchellh/mapstructure"
"github.com/nats-io/nats.go"
"log"
"os"
"path/filepath"
"strings"
)

// Config parsing process must at least yield the following minimal config scheme
type minimalConfig struct {
NatsUrl string `mapstructure:"NATS_URL"`
ServiceName string `mapstructure:"SERVICE_NAME"`
DataDir string `mapstructure:"DATA_DIR"`
}

// Checks if the untyped map contains all required config parameters to successfully start the service.
func assertContainsMinimalConfig(mapping map[string]any) error {
mConf := minimalConfig{}
func withServiceConfig(service *Service) error {
// First Pass
service.Config = MergeMaps(EnvArgsMap(), CliArgsMap())

decoderConfig := &mapstructure.DecoderConfig{
ErrorUnused: false,
ErrorUnset: true,
Result: &mConf,
// Dev Mode
if service.Config["DEV"] == true {
service.Config = MergeMaps(map[string]any{
"DATA_DIR": "/tmp",
"SERVICE_NAME": "dev",
"NATS_URL": "localhost:4222",
}, service.Config)
}

decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil {
// Decoder for minimal config inference could not be initialized!
return err
// Ensure Minimal Config
if service.Config["DATA_DIR"] == nil {
return fmt.Errorf("no DATA_DIR provided")
}

if err := decoder.Decode(mapping); err != nil {
// Minimal config could not be inferred from given map!
return fmt.Errorf("missing parameters in configuration. "+
"The following parameters are required: NATS_URL, SERVICE_NAME, DATA_DIR. "+
"Consider using --dev during development. Original error message: %s", err.Error())
service.DataDir = service.Config["DATA_DIR"].(string)
if service.Config["SERVICE_NAME"] == nil {
return fmt.Errorf("no SERVICE_NAME provided")
}

service.ServiceName = service.Config["SERVICE_NAME"].(string)
return nil
}

// Parses an untyped map into a service configuration.
func parseConfig(mapping *map[string]any) (*Config, error) {
// gets populated by the mapstructure decoder
config := Config{}

decoderConfig := &mapstructure.DecoderConfig{
ErrorUnused: false,
ErrorUnset: false,
WeaklyTypedInput: true,
Result: &config,
}

decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil {
// Decoder for TelestionBaseConfig inference could not be initialized!
return nil, err
}

if err := decoder.Decode(*mapping); err != nil {
// TelestionBaseConfig could not be inferred from given map!
return nil, err
}

return &config, nil
}

// Loads and parses the service [Config] from different configuration sources in the following order:
//
// 1. `overwriteArgs`
// 2. command line arguments
// 3. environment variables
// 4. default configuration, if `--dev` is passed in the steps from above
// 5. configuration file, if `CONFIG_FILE` parameter is defined, readable and parsable
func assembleConfig(overwriteArgs map[string]string) (*Config, error) {
config := &map[string]any{}

// add config params from passed service options
updateWith(config, &overwriteArgs)
// add config params from command line arguments
updateWith(config, cliConfig())
// add config params from environment variables
updateWith(config, envConfig())

// add default config if "dev" configuration is defined
if dev, ok := (*config)["DEV"].(bool); ok && dev {
fmt.Println("Running in development mode. Using default values for missing environment variables.")
dc, err := devModeDefaultConfig()
if err != nil {
return nil, err
// Merges multiple maps into one.
// If a key is present in multiple maps, the value of the last map is used.
func MergeMaps(maps ...map[string]any) map[string]any {
res := make(map[string]any)
for _, m := range maps {
for k, v := range m {
res[k] = v
}
updateWith(config, dc)
}

// add config file parameters if "CONFIG_FILE" is defined and readable
if configPath, ok := (*config)["CONFIG_FILE"].(string); ok && len(configPath) != 0 {
fc, err := fileConfig(configPath)
if err != nil {
return nil, err
}
updateWith(config, fc)
}

// verify if configuration is valid
if err := assertContainsMinimalConfig(*config); err != nil {
return nil, err
}

return parseConfig(config)
return res
}

// Adds entries from updates to base that don't exist in base.
func updateWith[V any | string](base *map[string]any, updates *map[string]V) {
for k, v := range *updates {
if _, contained := (*base)[k]; !contained {
(*base)[k] = v
// Returns a map of all CLI arguments in the form of --KEY=VALUE or --KEY VALUE
// where KEY is the uppercase key and VALUE is the value.
// If the argument is a flag (i.e., does not have a value), the value is true.
// Positional arguments are ignored.
func CliArgsMap() map[string]any {
res := make(map[string]any)
for /* don't include executable ($0) => start at 1 */ i := 1; i < len(os.Args); i++ {
key := os.Args[i]

if !strings.HasPrefix(key, "--") {
// ignore positional arguments
continue
}
}
}

// Parses the console arguments and returns a map that holds the configuration parameters.
func cliConfig() *map[string]any {
// setup flags
var (
dev bool
natsUrl string
natsUser string
natsPassword string
configFile string
configKey string
serviceName string
dataDir string
)

flag.BoolVar(&dev, "dev", false, "If set, program will start in development mode")

flag.StringVar(&natsUrl, "NATS_URL", "", "NATS url of the server the service can connect to")
flag.StringVar(&natsUser, "NATS_USER", "", "NATS user name for the authentication with the server")
flag.StringVar(&natsPassword, "NATS_PASSWORD", "", "NATS password for the authentication with the server "+
"(Note: It is recommended to set this via the environment variables or the config!)")

flag.StringVar(&configFile, "CONFIG_FILE", "", "file path to the config of the service")
flag.StringVar(&configKey, "CONFIG_KEY", "", "object key of a config file")

flag.StringVar(&serviceName, "SERVICE_NAME", "", "name of the service also used in the nats service "+
"registration")
flag.StringVar(&dataDir, "DATA_DIR", "", "path where the service can store persistent data")

// 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()

flagValues := map[string]any{
"NATS_URL": natsUrl,
"NATS_USER": natsUser,
"NATS_PASSWORD": natsPassword,
"CONFIG_FILE": configFile,
"CONFIG_KEY": configKey,
"SERVICE_NAME": serviceName,
"DATA_DIR": dataDir,
}

// prepare output map
parsedArgs := map[string]any{
"DEV": dev,
}

// only populate parsedArgs with entries that were, indeed, given (dev is an exception)
flag.Visit(func(currentFlag *flag.Flag) {
if value, ok := flagValues[currentFlag.Name]; ok {
parsedArgs[currentFlag.Name] = value
key = strings.TrimPrefix(key, "--")
if strings.Contains(key, "=") {
// --KEY=VALUE
split := strings.Split(key, "=")
innerKey := strings.ToUpper(split[0]) // all config keys are uppercase
innerValue := strings.Join(split[1:], "=")
res[innerKey] = innerValue
continue
}
})

return &parsedArgs
}

// Read the environment variables and provides them as map ready to be included in the service config.
func envConfig() *map[string]string {
result := make(map[string]string, len(os.Environ()))
for _, entry := range os.Environ() {
if key, value, ok := strings.Cut(entry, "="); ok {
result[key] = value
}
// we don't want to add empty env variables
}
return &result
}
// --KEY (VALUE)
// convert the key to uppercase as it doesn't contain the value in this case:
key = strings.ToUpper(key)

// Tries to read the configuration file and returns the content as untyped map.
// Fails, if the config file is not readable or if the content is not JSON parsable.
func fileConfig(configPath string) (*map[string]any, error) {
// Note that the file config is supposed to be a json config
jsonConfig := map[string]any{}
jsonConfigBytes, err := os.ReadFile(configPath)
hasValue := i+1 < len(os.Args) &&
!strings.HasPrefix(os.Args[i+1], "--")

if err != nil {
log.Printf("Config file %s could not be read: %s\n", configPath, err)
return nil, err
}
if hasValue {
// it's a key-value pair
res[key] = os.Args[i+1]
i++
continue
}

if err = json.Unmarshal(jsonConfigBytes, &jsonConfig); err != nil {
log.Printf("Config file %s could not be parsed: %s\n", configPath, err)
return nil, err
// it's a flag and not a key-value pair
res[key] = true
}

return &jsonConfig, nil
return res
}

// Returns the default configuration for development purposes.
// Fails, if the process is not allowed to determine the current working directory.
func devModeDefaultConfig() (*map[string]string, error) {
dataDir, err := filepath.Abs("data")
if err != nil {
return nil, err
}

return &map[string]string{
"NATS_URL": nats.DefaultURL,
"SERVICE_NAME": fmt.Sprint("dev-", os.Getgid()),
"DATA_DIR": dataDir,
}, nil
// Returns a map of all environment variables, where the key is the uppercase key
// and the value is the value of the environment variable.
//
// While this does always return a string, for compatibility with MergeMaps, it is
// typed as map[string]any.
func EnvArgsMap() map[string]any {
res := make(map[string]any)
for _, env := range os.Environ() {
split := strings.Split(env, "=")
key := strings.ToUpper(split[0]) // all config keys are uppercase
value := strings.Join(split[1:], "=")
res[key] = value
}
return res
}
Loading

0 comments on commit 8ed7943

Please sign in to comment.