Deploy MQTT test backend with private viewer and Gitea CI/CD
Test and deploy MQTT backend / test (push) Successful in 2m8s
Test and deploy MQTT backend / deploy (push) Successful in 18s

This commit is contained in:
2026-10-08 14:48:53 +03:30
commit 71ef622b4f
70 changed files with 7123 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
// Package config loads and validates configuration without opening network connections.
package config
import (
"fmt"
"net"
"net/url"
"os"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"github.com/knadh/koanf/parsers/toml/v2"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
const Prefix = "TEST_MQTT5_"
type Config struct {
MQTT MQTT `koanf:"mqtt"`
TLS TLS `koanf:"tls"`
Topics Topics `koanf:"topics"`
Deadlines Deadlines `koanf:"deadlines"`
Logging Logging `koanf:"logging"`
Limits Limits `koanf:"limits"`
Simulator Simulator `koanf:"simulator"`
Web Web `koanf:"web"`
Receipts Receipts `koanf:"receipts"`
}
type MQTT struct {
AllowPlaintext bool `koanf:"allow_plaintext"`
URL string `koanf:"url"`
ClientID string `koanf:"client_id"`
Username string `koanf:"username"`
Password string `koanf:"password"`
PasswordFile string `koanf:"password_file"`
QoS int `koanf:"qos"`
CleanStart bool `koanf:"clean_start"`
SessionExpiry time.Duration `koanf:"session_expiry"`
KeepAlive time.Duration `koanf:"keep_alive"`
ReconnectMin time.Duration `koanf:"reconnect_min"`
ReconnectMax time.Duration `koanf:"reconnect_max"`
}
type TLS struct {
CAFile string `koanf:"ca_file"`
ClientCert string `koanf:"client_cert"`
ClientKey string `koanf:"client_key"`
}
type Topics struct {
Namespace string `koanf:"namespace"`
DeviceID string `koanf:"device_id"`
ObserveDevices string `koanf:"observe_devices"`
ObserveAll bool `koanf:"observe_all"`
}
type Deadlines struct {
Connect time.Duration `koanf:"connect"`
Operation time.Duration `koanf:"operation"`
Command time.Duration `koanf:"command"`
Shutdown time.Duration `koanf:"shutdown"`
}
type Logging struct {
Level string `koanf:"level"`
Format string `koanf:"format"`
RawPayloads bool `koanf:"raw_payloads"`
}
type Limits struct {
PayloadBytes int `koanf:"payload_bytes"`
PreviewBytes int `koanf:"preview_bytes"`
PendingCommands int `koanf:"pending_commands"`
InboundQueue int `koanf:"inbound_queue"`
EventHistory int `koanf:"event_history"`
DedupEntries int `koanf:"dedup_entries"`
DedupRetention time.Duration `koanf:"dedup_retention"`
}
type Simulator struct {
TelemetryInterval time.Duration `koanf:"telemetry_interval"`
AckDelay time.Duration `koanf:"ack_delay"`
}
type Receipts struct {
Enabled bool `koanf:"enabled"`
}
type Web struct {
Enabled bool `koanf:"enabled"`
Address string `koanf:"address"`
MaxViewers int `koanf:"max_viewers"`
ViewerQueue int `koanf:"viewer_queue"`
}
func Defaults() Config {
return Config{
MQTT: MQTT{URL: "tls://localhost:8883", ClientID: "rm5-lab-observer-01", Username: "test-backend", QoS: 1, CleanStart: true, SessionExpiry: 5 * time.Minute, KeepAlive: 30 * time.Second, ReconnectMin: time.Second, ReconnectMax: 30 * time.Second},
Topics: Topics{Namespace: "robot-market/test/v1", DeviceID: "sim-001"},
Deadlines: Deadlines{Connect: 10 * time.Second, Operation: 5 * time.Second, Command: 15 * time.Second, Shutdown: 5 * time.Second},
Logging: Logging{Level: "info", Format: "text"},
Limits: Limits{PayloadBytes: 16384, PreviewBytes: 4096, PendingCommands: 128, InboundQueue: 256, EventHistory: 1000, DedupEntries: 1024, DedupRetention: 10 * time.Minute},
Simulator: Simulator{TelemetryInterval: 5 * time.Second},
Web: Web{Address: "127.0.0.1:8080", MaxViewers: 8, ViewerQueue: 64},
}
}
// Load uses only explicitly supplied overrides. Relative paths are relative to cwd.
// Errors deliberately exclude raw parser errors and values, which may contain secrets.
func Load(path string, overrides map[string]string) (Config, error) {
return LoadWithDefaults(path, overrides, Defaults())
}
// LoadWithDefaults applies role defaults below TOML, environment and explicit flags.
func LoadWithDefaults(path string, overrides map[string]string, cfg Config) (Config, error) {
fields := map[string]reflect.Value{}
collect(reflect.ValueOf(&cfg).Elem(), "", fields)
k := koanf.New(".")
for key, v := range fields {
value := v.Interface()
if v.Type() == reflect.TypeFor[time.Duration]() {
value = time.Duration(v.Int()).String()
}
if err := k.Set(key, value); err != nil {
return Config{}, fmt.Errorf("defaults: unable to set configuration")
}
}
if path != "" {
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return Config{}, fmt.Errorf("config: file must exist and be a readable regular file")
}
if info.Size() > 1<<20 {
return Config{}, fmt.Errorf("config: file exceeds 1 MiB")
}
data, err := file.Provider(path).ReadBytes()
if err != nil {
return Config{}, fmt.Errorf("config: unable to read file")
}
if len(data) > 1<<20 {
return Config{}, fmt.Errorf("config: file exceeds 1 MiB")
}
values, err := toml.Parser().Unmarshal(data)
if err != nil {
return Config{}, fmt.Errorf("config: invalid TOML; check syntax (values omitted)")
}
if err = checkKeys(values, "", fields); err != nil {
return Config{}, err
}
if err = setValues(k, values, ""); err != nil {
return Config{}, fmt.Errorf("config: unable to merge TOML")
}
}
// Read flat keys to reject ambiguous names before unflattening.
values, err := env.Provider(Prefix, "", func(key string) string {
return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(key, Prefix)), "__", ".")
}).Read()
if err != nil {
return Config{}, fmt.Errorf("environment: unable to read settings")
}
for key, value := range values {
if _, ok := fields[key]; !ok {
return Config{}, unknownKey(key)
}
if err = k.Set(key, value); err != nil {
return Config{}, fmt.Errorf("environment: unable to merge settings")
}
}
for key, value := range overrides {
if _, ok := fields[key]; !ok {
return Config{}, unknownKey(key)
}
if err = k.Set(key, value); err != nil {
return Config{}, fmt.Errorf("flags: unable to merge settings")
}
}
for key, field := range fields {
if err := assign(field, k.Get(key)); err != nil {
return Config{}, fmt.Errorf("%s: %s", key, err)
}
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func setValues(k *koanf.Koanf, values map[string]any, prefix string) error {
for key, value := range values {
if nested, ok := value.(map[string]any); ok {
if err := setValues(k, nested, prefix+key+"."); err != nil {
return err
}
} else if err := k.Set(prefix+key, value); err != nil {
return err
}
}
return nil
}
func collect(v reflect.Value, prefix string, out map[string]reflect.Value) {
for i := 0; i < v.NumField(); i++ {
key := prefix + v.Type().Field(i).Tag.Get("koanf")
f := v.Field(i)
if f.Kind() == reflect.Struct {
collect(f, key+".", out)
} else {
out[key] = f
}
}
}
func unknownKey(key string) error {
if !regexp.MustCompile(`^[a-z_][a-z0-9_.]{0,80}$`).MatchString(key) {
return fmt.Errorf("configuration: invalid or unknown key (name omitted)")
}
return fmt.Errorf("%s: unknown configuration key", key)
}
func checkKeys(values map[string]any, prefix string, fields map[string]reflect.Value) error {
for key, value := range values {
full := prefix + key
if nested, ok := value.(map[string]any); ok {
known := false
for field := range fields {
if strings.HasPrefix(field, full+".") {
known = true
break
}
}
if !known {
return unknownKey(full)
}
if err := checkKeys(nested, full+".", fields); err != nil {
return err
}
} else if _, ok := fields[full]; !ok {
return unknownKey(full)
}
}
return nil
}
func assign(field reflect.Value, value any) error {
if field.Type() == reflect.TypeFor[time.Duration]() {
s, ok := value.(string)
if !ok {
return fmt.Errorf("expected duration string with unit, e.g. 5s")
}
if !strings.ContainsAny(s, "nhµμmsu") {
return fmt.Errorf("expected duration string with unit, e.g. 5s")
}
d, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid or overflowing duration")
}
field.SetInt(int64(d))
return nil
}
switch field.Kind() {
case reflect.String:
s, ok := value.(string)
if !ok {
return fmt.Errorf("expected string")
}
field.SetString(s)
case reflect.Bool:
b, ok := value.(bool)
if !ok {
s, isString := value.(string)
if !isString || (s != "true" && s != "false") {
return fmt.Errorf("expected true or false")
}
b = s == "true"
}
field.SetBool(b)
case reflect.Int:
var n int64
switch v := value.(type) {
case int:
n = int64(v)
case int64:
n = v
case string:
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("expected integer within range")
}
n = parsed
default:
return fmt.Errorf("expected integer")
}
if field.OverflowInt(n) {
return fmt.Errorf("integer out of range")
}
field.SetInt(n)
default:
return fmt.Errorf("unsupported configuration type")
}
return nil
}
var identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
func (c Config) Validate() error {
bad := func(key, why string) error { return fmt.Errorf("%s: %s", key, why) }
u, err := url.Parse(c.MQTT.URL)
if err != nil || (u.Scheme != "tls" && !(u.Scheme == "mqtt" && c.MQTT.AllowPlaintext)) || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return bad("mqtt.url", "use tls://host:port; mqtt:// requires mqtt.allow_plaintext=true; omit credentials, path, query and fragment")
}
port, err := strconv.Atoi(u.Port())
if err != nil || port < 1 || port > 65535 {
return bad("mqtt.url", "explicit port must be 1..65535")
}
if !identifier.MatchString(c.MQTT.ClientID) {
return bad("mqtt.client_id", "use 1..64 letters, digits, underscores or hyphens, starting with a letter/digit")
}
if !identifier.MatchString(c.Topics.DeviceID) {
return bad("topics.device_id", "use 1..64 safe identifier characters")
}
if c.Topics.ObserveDevices != "" {
devices := strings.Split(c.Topics.ObserveDevices, ",")
if len(devices) > 16 {
return bad("topics.observe_devices", "maximum 16 device IDs")
}
for _, device := range devices {
if !identifier.MatchString(strings.TrimSpace(device)) {
return bad("topics.observe_devices", "use comma-separated safe device IDs")
}
}
}
if c.Topics.Namespace != "robot-market/test/v1" && !strings.HasPrefix(c.Topics.Namespace, "robot-market/test/v1/") {
return bad("topics.namespace", "must remain under robot-market/test/v1")
}
if len(c.Topics.Namespace) > 200 {
return bad("topics.namespace", "too long")
}
for _, part := range strings.Split(c.Topics.Namespace, "/") {
if !identifier.MatchString(part) {
return bad("topics.namespace", "segments must be nonempty safe identifiers")
}
}
if c.MQTT.QoS < 0 || c.MQTT.QoS > 1 {
return bad("mqtt.qos", "only 0 or 1 supported in this test environment")
}
if c.MQTT.Username == "" || len(c.MQTT.Username) > 128 {
return bad("mqtt.username", "required, maximum 128 bytes")
}
if c.MQTT.Password != "" && c.MQTT.PasswordFile != "" {
return bad("mqtt.password_file", "cannot be combined with mqtt.password")
}
if len(c.MQTT.Password) > 4096 {
return bad("mqtt.password", "maximum 4096 bytes")
}
if (c.TLS.ClientCert == "") != (c.TLS.ClientKey == "") {
return bad("tls.client_cert", "client_cert and client_key must be supplied together")
}
for key, path := range map[string]string{"tls.ca_file": c.TLS.CAFile, "tls.client_cert": c.TLS.ClientCert, "tls.client_key": c.TLS.ClientKey, "mqtt.password_file": c.MQTT.PasswordFile} {
if path != "" {
f, err := os.Open(path)
if err != nil {
return bad(key, "file must exist and be readable")
}
info, err := f.Stat()
f.Close()
if err != nil || !info.Mode().IsRegular() || info.Size() == 0 || info.Size() > 1<<20 {
return bad(key, "expected nonempty regular file up to 1 MiB")
}
}
}
if c.MQTT.SessionExpiry < 0 || c.MQTT.SessionExpiry%time.Second != 0 || c.MQTT.SessionExpiry/time.Second > 1<<32-1 {
return bad("mqtt.session_expiry", "must be whole seconds in 0..4294967295")
}
if c.MQTT.KeepAlive < time.Second || c.MQTT.KeepAlive%time.Second != 0 || c.MQTT.KeepAlive/time.Second > 65535 {
return bad("mqtt.keep_alive", "must be whole seconds in 1..65535")
}
for key, d := range map[string]time.Duration{"mqtt.reconnect_min": c.MQTT.ReconnectMin, "mqtt.reconnect_max": c.MQTT.ReconnectMax, "deadlines.connect": c.Deadlines.Connect, "deadlines.operation": c.Deadlines.Operation, "deadlines.shutdown": c.Deadlines.Shutdown, "simulator.telemetry_interval": c.Simulator.TelemetryInterval, "limits.dedup_retention": c.Limits.DedupRetention} {
if d <= 0 || d > 24*time.Hour {
return bad(key, "must be positive and no greater than 24h")
}
}
if c.MQTT.ReconnectMin > c.MQTT.ReconnectMax || c.MQTT.ReconnectMax > 30*time.Second {
return bad("mqtt.reconnect_max", "must be >= reconnect_min and <= 30s")
}
if c.Deadlines.Command < time.Second || c.Deadlines.Command > time.Minute || c.Deadlines.Command%time.Second != 0 {
return bad("deadlines.command", "must be whole seconds in 1s..1m")
}
if c.Limits.DedupRetention <= c.Deadlines.Command {
return bad("limits.dedup_retention", "must exceed command lifetime")
}
if c.Simulator.AckDelay < 0 || c.Simulator.AckDelay > time.Hour {
return bad("simulator.ack_delay", "must be in 0s..1h")
}
if c.Logging.Level != "debug" && c.Logging.Level != "info" && c.Logging.Level != "warn" && c.Logging.Level != "error" {
return bad("logging.level", "must be debug, info, warn or error")
}
if c.Logging.Format != "text" && c.Logging.Format != "json" {
return bad("logging.format", "must be text or json")
}
for key, n := range map[string]int{"limits.pending_commands": c.Limits.PendingCommands, "limits.inbound_queue": c.Limits.InboundQueue, "limits.event_history": c.Limits.EventHistory, "limits.dedup_entries": c.Limits.DedupEntries, "web.viewer_queue": c.Web.ViewerQueue} {
if n < 1 || n > 10000 {
return bad(key, "must be in 1..10000")
}
}
if c.Limits.PayloadBytes < 256 || c.Limits.PayloadBytes > 16384 {
return bad("limits.payload_bytes", "must be in 256..16384")
}
if c.Limits.PreviewBytes < 64 || c.Limits.PreviewBytes > 4096 || c.Limits.PreviewBytes > c.Limits.PayloadBytes {
return bad("limits.preview_bytes", "must be 64..4096 and no larger than payload_bytes")
}
if c.Web.ViewerQueue < 1 || c.Web.ViewerQueue > 256 {
return bad("web.viewer_queue", "must be in 1..256")
}
if c.Web.MaxViewers < 1 || c.Web.MaxViewers > 8 {
return bad("web.max_viewers", "must be 1..8")
}
host, portText, err := net.SplitHostPort(c.Web.Address)
p, portErr := strconv.Atoi(portText)
if err != nil || host != "127.0.0.1" || portErr != nil || p < 1 || p > 65535 {
return bad("web.address", "use 127.0.0.1 with a port in 1..65535")
}
return nil
}
+181
View File
@@ -0,0 +1,181 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func cleanEnv(t *testing.T) {
t.Helper()
for _, v := range os.Environ() {
key, _, _ := strings.Cut(v, "=")
if strings.HasPrefix(key, Prefix) {
t.Setenv(key, "")
os.Unsetenv(key)
}
}
}
func configFile(t *testing.T, text string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(p, []byte(text), 0600); err != nil {
t.Fatal(err)
}
return p
}
func TestPrecedence(t *testing.T) {
cleanEnv(t)
path := configFile(t, "[topics]\ndevice_id='file-device'\n[logging]\nlevel='debug'\n[mqtt]\nclean_start=false\n")
c, err := Load(path, nil)
if err != nil {
t.Fatal(err)
}
if c.Topics.DeviceID != "file-device" || c.Logging.Level != "debug" || c.MQTT.CleanStart {
t.Fatalf("TOML/default precedence failed")
}
t.Setenv(Prefix+"TOPICS__DEVICE_ID", "env-device")
t.Setenv(Prefix+"MQTT__SESSION_EXPIRY", "10m")
t.Setenv(Prefix+"MQTT__QOS", "0")
t.Setenv(Prefix+"MQTT__CLEAN_START", "true")
c, err = Load(path, nil)
if err != nil {
t.Fatal(err)
}
if c.Topics.DeviceID != "env-device" || c.MQTT.SessionExpiry != 10*time.Minute || c.MQTT.QoS != 0 || !c.MQTT.CleanStart {
t.Fatal("environment conversion failed")
}
c, err = Load(path, map[string]string{"topics.device_id": "cli-device"})
if err != nil {
t.Fatal(err)
}
if c.Topics.DeviceID != "cli-device" || c.Logging.Level != "debug" {
t.Fatal("explicit overrides failed")
}
}
func TestValidationAndSafeErrors(t *testing.T) {
cleanEnv(t)
cases := []struct{ name, body, key string }{
{"unknown", "[mqtt]\npasswrod='SUPER_SECRET'", "mqtt.passwrod"},
{"empty table", "[unexpected]", "unexpected"},
{"numeric duration", "[mqtt]\nkeep_alive=30", "mqtt.keep_alive"},
{"unitless duration", "[mqtt]\nkeep_alive='0'", "mqtt.keep_alive"},
{"overflow duration", "[mqtt]\nsession_expiry='999999999999999999999h'", "mqtt.session_expiry"},
{"fraction session", "[mqtt]\nsession_expiry='1.5s'", "mqtt.session_expiry"},
{"session max", "[mqtt]\nsession_expiry='4294967296s'", "mqtt.session_expiry"},
{"keepalive max", "[mqtt]\nkeep_alive='65536s'", "mqtt.keep_alive"},
{"negative", "[deadlines]\nconnect='-1s'", "deadlines.connect"},
{"qos", "[mqtt]\nqos=2", "mqtt.qos"},
{"fraction integer", "[mqtt]\nqos=0.5", "mqtt.qos"},
{"type mismatch", "[mqtt]\npassword=123", "mqtt.password"},
{"plain MQTT", "[mqtt]\nurl='tcp://localhost:1883'", "mqtt.url"},
{"userinfo", "[mqtt]\nurl='tls://user:SUPER_SECRET@localhost:8883'", "mqtt.url"},
{"port", "[mqtt]\nurl='tls://localhost:0'", "mqtt.url"},
{"wildcard", "[topics]\ndevice_id='sim/+'", "topics.device_id"},
{"production topic", "[topics]\nnamespace='device'", "topics.namespace"},
{"cert pair", "[tls]\nclient_key='SUPER_SECRET'", "tls.client_cert"},
{"secret source conflict", "[mqtt]\npassword='SUPER_SECRET'\npassword_file='secrets/password'", "mqtt.password_file"},
{"missing CA", "[tls]\nca_file='/missing/SUPER_SECRET'", "tls.ca_file"},
{"queue", "[limits]\ninbound_queue=0", "limits.inbound_queue"},
{"preview", "[limits]\npreview_bytes=20000", "limits.preview_bytes"},
{"retention", "[limits]\ndedup_retention='10s'", "limits.dedup_retention"},
{"command cap", "[deadlines]\ncommand='61s'", "deadlines.command"},
{"backoff", "[mqtt]\nreconnect_min='31s'", "mqtt.reconnect_max"},
{"public web", "[web]\naddress='0.0.0.0:8080'", "web.address"},
{"log level", "[logging]\nlevel='SUPER_SECRET'", "logging.level"},
{"syntax", "[mqtt]\npassword='SUPER_SECRET", "invalid TOML"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Load(configFile(t, tc.body), nil)
if err == nil || !strings.Contains(err.Error(), tc.key) {
t.Fatalf("expected %s error, got %v", tc.key, err)
}
if strings.Contains(err.Error(), "SUPER_SECRET") {
t.Fatal("secret leaked")
}
})
}
}
func TestEmptyEnvAndUnknown(t *testing.T) {
cleanEnv(t)
path := configFile(t, "[mqtt]\npassword='file-secret'\n")
t.Setenv(Prefix+"MQTT__PASSWORD", "")
c, err := Load(path, nil)
if err != nil {
t.Fatal(err)
}
if c.MQTT.Password != "" {
t.Fatal("empty env must clear password")
}
t.Setenv(Prefix+"MQTT__CLEAN_START", "1")
if _, err = Load(path, nil); err == nil {
t.Fatal("numeric boolean accepted")
}
os.Unsetenv(Prefix + "MQTT__CLEAN_START")
t.Setenv(Prefix+"UNKNOWN", "secret")
if _, err = Load(path, nil); err == nil {
t.Fatal("unknown env accepted")
}
}
func TestFilesAndBounds(t *testing.T) {
cleanEnv(t)
if _, err := Load("/missing/config", nil); err == nil {
t.Fatal("missing config accepted")
}
c := Defaults()
c.MQTT.SessionExpiry = 4294967295 * time.Second
c.MQTT.KeepAlive = 65535 * time.Second
if err := c.Validate(); err != nil {
t.Fatal(err)
}
secret := configFile(t, "test-password")
c.MQTT.PasswordFile = secret
if err := c.Validate(); err != nil {
t.Fatal(err)
}
c.MQTT.PasswordFile = t.TempDir()
if err := c.Validate(); err == nil {
t.Fatal("directory accepted as secret")
}
}
func TestPlaintextRequiresOptIn(t *testing.T) {
cfg := Defaults()
cfg.MQTT.URL = "mqtt://localhost:1884"
if cfg.Validate() == nil {
t.Fatal("plaintext accepted without opt-in")
}
cfg.MQTT.AllowPlaintext = true
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
cfg.MQTT.URL = "mqtt://user:secret@localhost:1884"
if cfg.Validate() == nil {
t.Fatal("embedded credentials accepted")
}
cfg.MQTT.URL = "tls://localhost:8884"
cfg.MQTT.AllowPlaintext = false
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
}
func TestRoleDefaultsRemainBelowConfiguration(t *testing.T) {
defaults := Defaults()
defaults.MQTT.ClientID = "role-default"
file := filepath.Join(t.TempDir(), "role.toml")
if err := os.WriteFile(file, []byte("[mqtt]\nclient_id='file-client'\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := LoadWithDefaults(file, nil, defaults)
if err != nil || cfg.MQTT.ClientID != "file-client" {
t.Fatal("role defaults overrode TOML")
}
cfg, err = LoadWithDefaults(file, map[string]string{"mqtt.client_id": "flag-client"}, defaults)
if err != nil || cfg.MQTT.ClientID != "flag-client" {
t.Fatal("explicit flags lost precedence")
}
}