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.
156 lines
4.9 KiB
Go
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
|
|
}
|