Files
mqtt-5-learning/internal/config/config.go
T
Ryan fb30e8e884 Refactor MQTT course and bound backend message processing
Move lesson source into the teaching structure, align the course path with the learner's Go and DevOps experience, and add the interactive lesson guides. Add advanced DevOps lifecycle study as a separate section.

Bound MQTT message work and drain accepted jobs during shutdown; align the backend implementation with the revised course through Section 6.
2026-10-22 10:19:56 +03:30

156 lines
4.9 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_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")
}
if strings.TrimSpace(c.MQTT.ClientID) == "" {
return fmt.Errorf("mqtt.client_id is required")
}
if strings.TrimSpace(c.MQTT.Username) == "" {
return fmt.Errorf("mqtt.username is required")
}
if strings.TrimSpace(c.MQTT.Password) == "" {
return fmt.Errorf("mqtt.password is required in the TOML configuration")
}
if !validDeviceID(c.MQTT.DeviceID) {
return fmt.Errorf("device ID must be 1-128 ASCII letters, digits, dots, underscores, or hyphens")
}
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
}
// validDeviceID mirrors the topic contract's single-segment ID rules so
// configuration fails before the process tries to build MQTT topics.
func validDeviceID(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, r := range value {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) {
return false
}
}
return true
}