feat: config and config loader implemented
This commit is contained in:
Generated
+1
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/references/paho.golang" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/references/paho.golang-v0.23.0" vcs="Git" />
|
||||
</component>
|
||||
|
||||
@@ -53,14 +53,12 @@ func run() error {
|
||||
OnConnectError: func(err error) {
|
||||
log.Printf("connect attempt failed: %v", err)
|
||||
},
|
||||
ClientConfig: paho.ClientConfig{
|
||||
ClientID: "lab-backend",
|
||||
OnClientError: func(err error) {
|
||||
log.Printf("client error: %v", err)
|
||||
},
|
||||
OnServerDisconnect: func(packet *paho.Disconnect) {
|
||||
log.Printf("server disconnected: reason=0x%02x", packet.ReasonCode)
|
||||
},
|
||||
ClientID: "lab-backend",
|
||||
OnClientError: func(err error) {
|
||||
log.Printf("client error: %v", err)
|
||||
},
|
||||
OnServerDisconnect: func(packet *paho.Disconnect) {
|
||||
log.Printf("server disconnected: reason=0x%02x", packet.ReasonCode)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[mqtt]
|
||||
url = "mqtt://127.0.0.1:18883"
|
||||
client_id = "lab-backend"
|
||||
username = "backend"
|
||||
device_id = "device-001"
|
||||
|
||||
[app]
|
||||
connect_timeout = "10s"
|
||||
@@ -5,6 +5,17 @@ go 1.27.1
|
||||
require github.com/eclipse/paho.golang v0.23.0
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/knadh/koanf/parsers/toml/v2 v2.2.0 // indirect
|
||||
github.com/knadh/koanf/providers/confmap v1.0.0 // indirect
|
||||
github.com/knadh/koanf/providers/file v1.2.0 // indirect
|
||||
github.com/knadh/koanf/v2 v2.3.7 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
)
|
||||
|
||||
@@ -2,10 +2,30 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
||||
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||
github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A=
|
||||
github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI=
|
||||
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
|
||||
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
|
||||
github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
|
||||
github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
||||
github.com/knadh/koanf/v2 v2.3.7 h1:amceufOeoQcq6VFKjm7/ggJ3t0Dkqaxy5fza4j3YgTA=
|
||||
github.com/knadh/koanf/v2 v2.3.7/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
@@ -14,5 +34,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/koanf/parsers/toml/v2"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MQTT struct {
|
||||
URL string `koanf:"url"`
|
||||
ClientID string `koanf:"client_id"`
|
||||
Username string `koanf:"username"`
|
||||
Password string `koanf:"password"`
|
||||
DeviceID string `koanf:"device_id"`
|
||||
} `koanf:"mqtt"`
|
||||
App struct {
|
||||
ConnectTimeout string `koanf:"connect_timeout"`
|
||||
} `koanf:"app"`
|
||||
}
|
||||
|
||||
// Keep duration input as text; validate/parse it explicitly after merging.
|
||||
func (c Config) ConnectTimeout() time.Duration {
|
||||
d, _ := time.ParseDuration(c.App.ConnectTimeout) // Load already validated it.
|
||||
return d
|
||||
}
|
||||
|
||||
func Load(args []string) (Config, error) {
|
||||
var result Config
|
||||
flags := flag.NewFlagSet("mqtt5-learning", flag.ContinueOnError)
|
||||
filename := flags.String("config", "configs/lab.toml", "TOML file")
|
||||
broker := flags.String("broker", "", "broker URL override")
|
||||
clientID := flags.String("client-id", "", "client ID override")
|
||||
username := flags.String("username", "", "lab username override")
|
||||
deviceID := flags.String("device-id", "", "device identity override")
|
||||
timeout := flags.String("connect-timeout", "", "startup deadline override")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return result, fmt.Errorf("unexpected positional arguments")
|
||||
}
|
||||
|
||||
k := koanf.New(".")
|
||||
defaults := map[string]any{
|
||||
"mqtt.url": "mqtt://127.0.0.1:18883",
|
||||
"mqtt.client_id": "lab-backend",
|
||||
"mqtt.username": "", "mqtt.password": "",
|
||||
"mqtt.device_id": "device-001",
|
||||
"app.connect_timeout": "10s",
|
||||
}
|
||||
if err := k.Load(confmap.Provider(defaults, "."), nil); err != nil {
|
||||
return result, fmt.Errorf("defaults: %w", err)
|
||||
}
|
||||
// A requested/missing config file is an error, not a silent fallback.
|
||||
if err := k.Load(file.Provider(*filename), toml.Parser()); err != nil {
|
||||
return result, fmt.Errorf("TOML: %w", err)
|
||||
}
|
||||
|
||||
// Explicit mapping preserves underscores inside names such as client_id.
|
||||
mapping := map[string]string{
|
||||
"TEST_MQTT5_MQTT_URL": "mqtt.url",
|
||||
"TEST_MQTT5_MQTT_CLIENT_ID": "mqtt.client_id",
|
||||
"TEST_MQTT5_MQTT_USERNAME": "mqtt.username",
|
||||
"TEST_MQTT5_MQTT_PASSWORD": "mqtt.password",
|
||||
"TEST_MQTT5_MQTT_DEVICE_ID": "mqtt.device_id",
|
||||
"TEST_MQTT5_APP_CONNECT_TIMEOUT": "app.connect_timeout",
|
||||
}
|
||||
for name, key := range mapping {
|
||||
if value, exists := os.LookupEnv(name); exists {
|
||||
if err := k.Set(key, value); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
overrides := map[string]string{}
|
||||
flags.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "broker":
|
||||
overrides["mqtt.url"] = *broker
|
||||
case "client-id":
|
||||
overrides["mqtt.client_id"] = *clientID
|
||||
case "username":
|
||||
overrides["mqtt.username"] = *username
|
||||
case "device-id":
|
||||
overrides["mqtt.device_id"] = *deviceID
|
||||
case "connect-timeout":
|
||||
overrides["app.connect_timeout"] = *timeout
|
||||
}
|
||||
})
|
||||
for key, value := range overrides {
|
||||
if err := k.Set(key, value); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := k.Unmarshal("", &result); err != nil {
|
||||
return result, fmt.Errorf("decode configuration: %w", err)
|
||||
}
|
||||
if err := result.Validate(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
u, err := url.Parse(c.MQTT.URL)
|
||||
if err != nil || u == nil || u.Hostname() == "" || u.Port() == "" {
|
||||
return fmt.Errorf("mqtt.url must contain a valid host and explicit port")
|
||||
}
|
||||
// Early lab only. TLS configuration is deliberately introduced later.
|
||||
if u.Scheme != "mqtt" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return fmt.Errorf("early lab requires mqtt://host:port without credentials or extra URL components")
|
||||
}
|
||||
if u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" {
|
||||
return fmt.Errorf("plaintext learning broker must be on loopback")
|
||||
}
|
||||
for _, value := range []string{c.MQTT.ClientID, c.MQTT.Username, c.MQTT.Password} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("client ID and credentials are required")
|
||||
}
|
||||
}
|
||||
if c.MQTT.DeviceID == "" || strings.ContainsAny(c.MQTT.DeviceID, "/+# \t\r\n") {
|
||||
return fmt.Errorf("device ID must be a nonempty single topic segment")
|
||||
}
|
||||
d, err := time.ParseDuration(c.App.ConnectTimeout)
|
||||
if err != nil || d <= 0 || d > time.Minute {
|
||||
return fmt.Errorf("connect timeout must be a duration greater than zero and at most 1m")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user