Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user