216 lines
6.7 KiB
Go
216 lines
6.7 KiB
Go
// Package cli shares safe flag parsing and role-specific MQTT startup.
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"time"
|
|
|
|
"robot-market/test-mqtt-5/internal/backend"
|
|
"robot-market/test-mqtt-5/internal/config"
|
|
"robot-market/test-mqtt-5/internal/events"
|
|
"robot-market/test-mqtt-5/internal/mqtt"
|
|
"robot-market/test-mqtt-5/internal/simulator"
|
|
"robot-market/test-mqtt-5/internal/web"
|
|
)
|
|
|
|
func Run(ctx context.Context, args []string, out, errOut io.Writer, sim bool) int {
|
|
name := "test-mqtt5"
|
|
if sim {
|
|
name = "device-sim"
|
|
}
|
|
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
path := fs.String("config", "", "TOML file path (optional)")
|
|
device := fs.String("device-id", "", "Override topics.device_id")
|
|
level := fs.String("log-level", "", "Override logging.level")
|
|
credentials := fs.String("credentials", "", "Private deployment client JSON file (username/password only)")
|
|
broker := fs.String("broker", "", "Override mqtt.url")
|
|
ca := fs.String("ca", "", "Override tls.ca_file")
|
|
clientID := fs.String("client-id", "", "Override mqtt.client_id; use a distinct ID for each process")
|
|
plaintext := fs.Bool("allow-plaintext", false, "Explicitly permit mqtt:// test connections")
|
|
requestID := fs.String("request-id", "", "Command request ID; generated when omitted")
|
|
fs.Usage = func() {
|
|
if sim {
|
|
fmt.Fprintln(out, "Usage: device-sim [FLAGS] [check-config]\nRuns the harmless MQTT 5 simulator by default.")
|
|
} else {
|
|
fmt.Fprintln(out, "Usage: test-mqtt5 [FLAGS] check-config|observe|command ping|get_version\nFlags precede the subcommand.")
|
|
}
|
|
fs.SetOutput(out)
|
|
fs.PrintDefaults()
|
|
fs.SetOutput(io.Discard)
|
|
}
|
|
if err := fs.Parse(args); err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
return 0
|
|
}
|
|
fmt.Fprintln(errOut, "invalid CLI arguments; use --help (values omitted)")
|
|
return 2
|
|
}
|
|
mode := "simulate"
|
|
command := ""
|
|
if !sim {
|
|
if fs.NArg() < 1 {
|
|
fmt.Fprintln(errOut, "expected check-config, observe or command; use --help")
|
|
return 2
|
|
}
|
|
mode = fs.Arg(0)
|
|
if mode == "command" {
|
|
if fs.NArg() != 2 || (fs.Arg(1) != "ping" && fs.Arg(1) != "get_version") {
|
|
fmt.Fprintln(errOut, "command requires ping or get_version")
|
|
return 2
|
|
}
|
|
command = fs.Arg(1)
|
|
} else if fs.NArg() != 1 || (mode != "observe" && mode != "check-config") {
|
|
fmt.Fprintln(errOut, "expected check-config, observe or command; use --help")
|
|
return 2
|
|
}
|
|
} else if fs.NArg() != 0 {
|
|
if fs.NArg() != 1 || fs.Arg(0) != "check-config" {
|
|
fmt.Fprintln(errOut, "simulator accepts only check-config or no subcommand")
|
|
return 2
|
|
}
|
|
mode = "check-config"
|
|
}
|
|
if ctx.Err() != nil {
|
|
fmt.Fprintln(errOut, "configuration check interrupted")
|
|
return 1
|
|
}
|
|
overrides := map[string]string{}
|
|
defaults := config.Defaults()
|
|
// Role defaults remain below TOML, environment and explicit flags.
|
|
if sim {
|
|
defaults.MQTT.ClientID = "rm5-lab-simulator-01"
|
|
defaults.MQTT.Username = "sim-001"
|
|
}
|
|
if mode == "command" {
|
|
defaults.MQTT.ClientID = "rm5-lab-command-" + backend.ID()[:12]
|
|
}
|
|
if *credentials != "" {
|
|
data, err := os.ReadFile(*credentials)
|
|
if err != nil || len(data) > 1<<20 {
|
|
fmt.Fprintln(errOut, "cannot read private client credentials")
|
|
return 1
|
|
}
|
|
var record struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if json.Unmarshal(data, &record) != nil || record.Username == "" || record.Password == "" {
|
|
fmt.Fprintln(errOut, "invalid private client credentials")
|
|
return 1
|
|
}
|
|
overrides["mqtt.username"] = record.Username
|
|
overrides["mqtt.password"] = record.Password
|
|
overrides["mqtt.password_file"] = ""
|
|
}
|
|
fs.Visit(func(f *flag.Flag) {
|
|
switch f.Name {
|
|
case "device-id":
|
|
overrides["topics.device_id"] = *device
|
|
case "log-level":
|
|
overrides["logging.level"] = *level
|
|
case "broker":
|
|
overrides["mqtt.url"] = *broker
|
|
case "ca":
|
|
overrides["tls.ca_file"] = *ca
|
|
case "client-id":
|
|
overrides["mqtt.client_id"] = *clientID
|
|
case "allow-plaintext":
|
|
overrides["mqtt.allow_plaintext"] = fmt.Sprint(*plaintext)
|
|
}
|
|
})
|
|
cfg, err := config.LoadWithDefaults(*path, overrides, defaults)
|
|
if err != nil {
|
|
fmt.Fprintln(errOut, "configuration invalid:", err)
|
|
return 1
|
|
}
|
|
if cfg.Web.Enabled && mode != "observe" {
|
|
fmt.Fprintln(errOut, "web.enabled requires observe mode")
|
|
return 1
|
|
}
|
|
if (cfg.Topics.ObserveDevices != "" || cfg.Topics.ObserveAll) && mode != "observe" && mode != "check-config" {
|
|
fmt.Fprintln(errOut, "topic observation settings require observe mode")
|
|
return 1
|
|
}
|
|
if cfg.Receipts.Enabled && (mode != "observe" && mode != "check-config" || !cfg.Topics.ObserveAll) {
|
|
fmt.Fprintln(errOut, "receipt replies require raw observe mode")
|
|
return 1
|
|
}
|
|
var logLevel slog.Level
|
|
_ = logLevel.UnmarshalText([]byte(cfg.Logging.Level))
|
|
options := &slog.HandlerOptions{Level: logLevel}
|
|
var handler slog.Handler = slog.NewTextHandler(out, options)
|
|
if cfg.Logging.Format == "json" {
|
|
handler = slog.NewJSONHandler(out, options)
|
|
}
|
|
log := slog.New(handler)
|
|
if mode == "check-config" {
|
|
log.Log(ctx, logLevel, "configuration valid", "event", "config_valid", "network_checked", false, "credentials_checked", false)
|
|
return 0
|
|
}
|
|
if !cfg.MQTT.CleanStart {
|
|
fmt.Fprintln(errOut, "runtime requires clean_start=true on initial process connection; durable client recovery is not implemented")
|
|
return 1
|
|
}
|
|
store := events.NewStore(cfg.Limits.EventHistory, log)
|
|
client := mqtt.New(cfg, store)
|
|
if sim {
|
|
err = simulator.New(client).Run(ctx)
|
|
} else {
|
|
b := backend.New(client)
|
|
var viewer *web.Viewer
|
|
if cfg.Web.Enabled {
|
|
viewer = web.New(cfg.Web, store, cfg.Deadlines.Shutdown)
|
|
if err = viewer.Start(); err != nil {
|
|
fmt.Fprintln(errOut, err)
|
|
return 1
|
|
}
|
|
}
|
|
if err = client.Start(ctx, []string{"telemetry", "status", "commands", "acks"}, nil); err == nil {
|
|
if mode == "observe" {
|
|
err = b.Serve(ctx)
|
|
} else {
|
|
workerCtx, cancel := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
go func() { done <- b.Serve(workerCtx) }()
|
|
id := *requestID
|
|
if id == "" {
|
|
id = backend.ID()
|
|
}
|
|
_, err = b.Command(ctx, backend.NewCommand(cfg.Topics.DeviceID, command, id, cfg.Deadlines.Command))
|
|
cancel()
|
|
workerErr := <-done
|
|
if err == nil {
|
|
err = workerErr
|
|
}
|
|
}
|
|
closeErr := client.Close()
|
|
if err == nil {
|
|
err = closeErr
|
|
}
|
|
}
|
|
if viewer != nil {
|
|
shutdown, stop := context.WithTimeout(context.Background(), cfg.Deadlines.Shutdown)
|
|
viewErr := viewer.Close(shutdown)
|
|
stop()
|
|
if err == nil {
|
|
err = viewErr
|
|
}
|
|
}
|
|
log.Info("backend counters", "event", "counters", "summary", b.Summary())
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(errOut, "operation failed:", err)
|
|
return 1
|
|
}
|
|
log.Info("shutdown complete", "event", "shutdown_complete", "time", time.Now().UTC())
|
|
return 0
|
|
}
|