Files
mqtt-5-learning/internal/config/config.go
T

139 lines
4.4 KiB
Go

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
}